写入时复制 (CoW)#

注意

写入时复制将在 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

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

迁移到写入时复制#

写入时复制将是 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])

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

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

每次只更新一个 pandas 对象

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

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 规则明确禁止这样做。这包括将单列作为 Series 更新并依赖更改传播回父 DataFrame。如果需要此行为,此语句可以使用 lociloc 重写为单个语句。DataFrame.where() 是此情况下的另一个合适替代方案。

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

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 数组。此更改是为了避免在 NumPy 数组在 pandas 外部进行原地更改时修改 pandas 对象。您可以设置 copy=False 来避免此复制。

描述#

CoW 意味着任何以任何方式从另一个 DataFrame 或 Series 派生的 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 则被修改。

如果执行 reset_index 操作后不再需要对象 df,您可以通过将 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

df 被更改时,CoW 会触发复制,以避免 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
   ....: 

bar 列的值大于 5 时,foo 列会被更新。但这违反了 CoW 原则,因为它必须在一个步骤中修改视图 df["foo"]df。因此,启用 CoW 后,链式赋值将始终不起作用,并引发 ChainedAssignmentError 警告。

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

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

写入时复制优化#

一种新的延迟复制机制,它将复制推迟到相关对象被修改时才进行,并且仅当此对象与另一个对象共享数据时。此机制已添加到不需要复制底层数据的方法中。常见的例子是当 axis=1 时的 DataFrame.drop()DataFrame.rename()

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

如何启用 CoW#

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

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

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