写时复制 (CoW) #

笔记

Copy-on-Write 将成为 pandas 3.0 中的默认设置。我们建议 立即将其打开, 以便从所有改进中受益。

写入时复制在 1.5.0 版本中首次引入。从版本 2.0 开始,通过 CoW 实现的大部分优化都得到了实现和支持。从 pandas 2.1 开始支持所有可能的优化。

CoW 在 3.0 版本中将默认启用。

CoW 将导致更可预测的行为,因为不可能用一条语句更新多个对象,例如索引操作或方法不会产生副作用。此外,通过尽可能延迟复制,平均性能和内存使用率将会提高。

之前的行为#

pandas 的索引行为很难理解。某些操作返回视图,而其他操作则返回副本。根据操作的结果,改变一个对象可能会意外地改变另一个对象:

In [1]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [2]: subset = df["foo"]

In [3]: subset.iloc[0] = 100

In [4]: df
Out[4]: 
   foo  bar
0  100    4
1    2    5
2    3    6

改变subset,例如更新其值,也会更新df。确切的行为很难预测。写入时复制解决了意外修改多个对象的问题,它明确禁止这样做。启用 CoW 后,df不变:

In [5]: pd.options.mode.copy_on_write = True

In [6]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [7]: subset = df["foo"]

In [8]: subset.iloc[0] = 100

In [9]: df
Out[9]: 
   foo  bar
0    1    4
1    2    5
2    3    6

以下部分将解释这意味着什么以及它如何影响现有应用程序。

迁移到写入时复制#

Copy-on-Write 将是 pandas 3.0 中默认且唯一的模式。这意味着用户需要迁移其代码以符合 CoW 规则。

pandas 中的默认模式将在某些情况下发出警告,这些情况将主动改变行为,从而改变用户预期的行为。

我们添加了另一种模式,例如

pd.options.mode.copy_on_write = "warn"

这将对每一个会改变 CoW 行为的操作发出警告。我们预计这种模式会非常嘈杂,因为在很多情况下我们预计它们不会影响用户,但也会发出警告。我们建议检查此模式并分析警告,但没有必要解决所有这些警告。以下列表中的前两项是使现有代码与 CoW 兼容需要解决的唯一情况。

以下几项描述了用户可见的更改:

链式分配永远不会起作用

loc应用作替代方案。检查 链接分配部分以获取更多详细信息。

访问 pandas 对象的底层数组将返回只读视图

In [10]: ser = pd.Series([1, 2, 3])

In [11]: ser.to_numpy()
Out[11]: array([1, 2, 3])

此示例返回一个 NumPy 数组,它是 Series 对象的视图。可以修改此视图,从而也可以修改 pandas 对象。这不符合 CoW 规则。返回的数组被设置为不可写以防止这种行为。创建该数组的副本允许修改。如果您不再关心 pandas 对象,也可以使数组再次可写。

有关更多详细信息,请参阅有关只读 NumPy 数组的部分。

一次仅更新一个 pandas 对象

以下代码片段dfsubset不使用 CoW 的情况下进行更新:

In [12]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [13]: subset = df["foo"]

In [14]: subset.iloc[0] = 100

In [15]: df
Out[15]: 
   foo  bar
0    1    4
1    2    5
2    3    6

对于 CoW,这将不再可能,因为 CoW 规则明确禁止这样做。这包括将单个列更新为 aSeries并依赖于传播回父级的更改DataFrame。如果需要此行为,可以使用loc或将此语句重写为单个语句。对于这种情况,是另一种合适的选择。ilocDataFrame.where()

DataFrame使用就地方法更新从 a 中选择的列也将不再起作用。

In [16]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [17]: df["foo"].replace(1, 5, inplace=True)

In [18]: df
Out[18]: 
   foo  bar
0    1    4
1    2    5
2    3    6

这是链式赋值的另一种形式。这通常可以重写为两种不同的形式:

In [19]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [20]: df.replace({"foo": {1: 5}}, inplace=True)

In [21]: df
Out[21]: 
   foo  bar
0    5    4
1    2    5
2    3    6

另一种选择是不使用inplace

In [22]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [23]: df["foo"] = df["foo"].replace(1, 5)

In [24]: df
Out[24]: 
   foo  bar
0    5    4
1    2    5
2    3    6

构造函数现在默认复制 NumPy 数组

如果没有另外指定,Series 和 DataFrame 构造函数现在将默认复制 NumPy 数组。对此进行更改是为了避免在 Pandas 外部更改 NumPy 数组时改变 pandas 对象。您可以设置copy=False避免此副本。

描述

CoW 意味着任何以任何方式从另一个派生的 DataFrame 或 Series 始终表现为副本。因此,我们只能通过修改对象本身来改变对象的值。 CoW 不允许更新与另一个 DataFrame 或 Series 对象共享数据的 DataFrame 或 Series。

这避免了修改值时的副作用,因此,大多数方法可以避免实际复制数据,而仅在必要时触发复制。

以下示例将在 CoW 中就地运行:

In [25]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [26]: df.iloc[0, 0] = 100

In [27]: df
Out[27]: 
   foo  bar
0  100    4
1    2    5
2    3    6

该对象df不与任何其他对象共享任何数据,因此更新值时不会触发任何副本。相比之下,以下操作会触发 CoW 下的数据副本:

In [28]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [29]: df2 = df.reset_index(drop=True)

In [30]: df2.iloc[0, 0] = 100

In [31]: df
Out[31]: 
   foo  bar
0    1    4
1    2    5
2    3    6

In [32]: df2
Out[32]: 
   foo  bar
0  100    4
1    2    5
2    3    6

reset_index使用 CoW 返回惰性副本,同时复制不使用 CoW 的数据。由于两个对象dfdf2共享相同的数据,因此在修改 时会触发副本df2。该对象仍然具有与修改df时最初相同的值。df2

df如果执行操作后不再需要该对象reset_index,您可以通过将 的输出分配reset_index 给同一变量来模拟类似就地的操作:

In [33]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [34]: df = df.reset_index(drop=True)

In [35]: df.iloc[0, 0] = 100

In [36]: df
Out[36]: 
   foo  bar
0  100    4
1    2    5
2    3    6

一旦reset_index重新分配结果,初始对象就会超出范围,因此df不会与任何其他对象共享数据。修改对象时不需要复制。对于写入时复制优化中列出的所有方法来说通常都是如此。

以前,在操作视图时,视图和父对象都会被修改:

In [37]: with pd.option_context("mode.copy_on_write", False):
   ....:     df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
   ....:     view = df[:]
   ....:     df.iloc[0, 0] = 100
   ....: 

In [38]: df
Out[38]: 
   foo  bar
0  100    4
1    2    5
2    3    6

In [39]: view
Out[39]: 
   foo  bar
0  100    4
1    2    5
2    3    6

dfCoW 在更改时触发副本以避免变异view

In [40]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [41]: view = df[:]

In [42]: df.iloc[0, 0] = 100

In [43]: df
Out[43]: 
   foo  bar
0  100    4
1    2    5
2    3    6

In [44]: view
Out[44]: 
   foo  bar
0    1    4
1    2    5
2    3    6

链式分配#

链式赋值引用了一种通过两个后续索引操作更新对象的技术,例如

In [45]: with pd.option_context("mode.copy_on_write", False):
   ....:     df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
   ....:     df["foo"][df["bar"] > 5] = 100
   ....:     df
   ....: 

foo当列bar大于 5 时,该列就会被更新。但这违反了 CoW 原则,因为它必须df["foo"]一步修改视图df。因此,链式分配将始终无法工作,并ChainedAssignmentError在启用 CoW 的情况下发出警告:

In [46]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})

In [47]: df["foo"][df["bar"] > 5] = 100

通过写时复制,可以使用loc.

In [48]: df.loc[df["bar"] > 5, "foo"] = 100

只读 NumPy 数组#

如果数组与初始 DataFrame 共享数据,则访问 DataFrame 的底层 NumPy 数组将返回一个只读数组:

如果初始 DataFrame 由多个数组组成,则该数组是一个副本:

In [49]: df = pd.DataFrame({"a": [1, 2], "b": [1.5, 2.5]})

In [50]: df.to_numpy()
Out[50]: 
array([[1. , 1.5],
       [2. , 2.5]])

如果 DataFrame 仅包含一个 NumPy 数组,则该数组与 DataFrame 共享数据:

In [51]: df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})

In [52]: df.to_numpy()
Out[52]: 
array([[1, 3],
       [2, 4]])

该数组是只读的,这意味着它不能就地修改:

In [53]: arr = df.to_numpy()

In [54]: arr[0, 0] = 100
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[54], line 1
----> 1 arr[0, 0] = 100

ValueError: assignment destination is read-only

对于 Series 也是如此,因为 Series 始终由单个数组组成。

对此有两种可能的解决方案:

  • 如果您想避免更新与阵列共享内存的 DataFrame,请手动触发副本。

  • 使数组可写。这是一种性能更高的解决方案,但规避了写入时复制规则,因此应谨慎使用。

In [55]: arr = df.to_numpy()

In [56]: arr.flags.writeable = True

In [57]: arr[0, 0] = 100

In [58]: arr
Out[58]: 
array([[100,   3],
       [  2,   4]])

要避免的模式#

如果在就地修改一个对象时两个对象共享相同的数据,则不会执行防御性复制。

In [59]: df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

In [60]: df2 = df.reset_index(drop=True)

In [61]: df2.iloc[0, 0] = 100

这将创建两个共享数据的对象,因此 setitem 操作将触发复制。如果df不再需要初始对象,则不需要这样做。简单地重新分配给同一变量将使对象所持有的引用无效。

In [62]: df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

In [63]: df = df.reset_index(drop=True)

In [64]: df.iloc[0, 0] = 100

在此示例中不需要复制。创建多个引用会使不必要的引用保持活动状态,因此会损害写入时复制的性能。

写时复制优化#

一种新的惰性复制机制,可以推迟复制,直到修改相关对象,并且仅当该对象与另一个对象共享数据时。此机制已添加到不需要底层数据副本的方法中。流行的例子是DataFrame.drop()axis=1DataFrame.rename()

这些方法在启用写入时复制时返回视图,与常规执行相比,这提供了显着的性能改进。

如何启用 CoW #

可以通过配置选项启用写入时复制copy_on_write。可以通过以下任一方式打开 __globally__ 选项:

In [65]: pd.set_option("mode.copy_on_write", True)

In [66]: pd.options.mode.copy_on_write = True