Copy-on-Write (CoW)#

注意

Copy-on-Write 现在是 pandas 3.0 的默认行为。

Copy-on-Write 最初在 1.5.0 版本中引入。从 2.0 版本开始,通过 CoW 实现和支持的大部分优化已集成。所有可能的优化都从 pandas 2.1 开始支持。

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。确切的行为很难预测。Copy-on-Write 解决了意外修改多个对象的问题,它明确禁止这样做。df 未更改。

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    1    4
1    2    5
2    3    6

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

迁移到 Copy-on-Write#

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

pandas < 3.0 的默认模式会针对某些将主动改变行为从而改变用户预期行为的情况发出警告。

pandas 2.2 有一个警告模式

pd.options.mode.copy_on_write = "warn"

该模式将针对每一个与 CoW 行为不同的操作发出警告。我们预计此模式将非常嘈杂,因为许多我们认为不会影响用户的案例也将发出警告。我们建议检查此模式并分析警告,但并非必须解决所有这些警告。以下列表的前两项是使现有代码与 CoW 兼容所需要解决的唯一问题。

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

链式赋值将永远无法工作

应使用 loc 作为替代。有关更多详细信息,请参阅 链式赋值部分

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

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

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

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

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

一次只更新一个 pandas 对象

以下代码片段在没有 CoW 的情况下更新了 dfsubset

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

使用 CoW 不再可能发生这种情况,因为 CoW 规则明确禁止。这包括将单个列更新为 Series 并依赖更改传播回父 DataFrame。如果需要此行为,此语句可以重写为使用 lociloc 的单个语句。DataFrame.where() 是此情况的另一个适用替代方案。

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

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

In [8]: df["foo"].replace(1, 5, inplace=True)
Out[8]: 
0    5
1    2
2    3
Name: foo, dtype: int64

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

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

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

In [11]: df.replace({"foo": {1: 5}}, inplace=True)
Out[11]: 
   foo  bar
0    5    4
1    2    5
2    3    6

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

另一种替代方法是不使用 inplace

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

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

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

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

Series 和 DataFrame 构造函数在未另行指定时,现在默认会复制 NumPy 数组。更改此项是为了避免在 NumPy 数组在 pandas 外部被 inplace 修改时修改 pandas 对象。您可以设置 copy=False 来避免此复制。

描述#

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

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

以下示例将执行 inplace 操作

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

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

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

对象 df 不与任何其他对象共享数据,因此在更新值时不会触发复制。相反,以下操作在 CoW 下触发数据复制。

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

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

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

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

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

reset_index 在 CoW 下返回一个惰性副本,而在没有 CoW 的情况下复制数据。由于 dfdf2 这两个对象共享相同的数据,因此在修改 df2 时会触发复制。df 对象仍然具有与初始时相同的值,而 df2 已被修改。

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

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

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

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

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

reset_index 的结果被重新分配后,初始对象立即超出范围,因此 df 不再与任何其他对象共享数据。修改对象时无需复制。这对于 Copy-on-Write 优化 中列出的所有方法通常都适用。

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

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

CoW 在更改 df 时触发复制,以避免同时修改 view

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

In [29]: view = df[:]

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

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

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

链式赋值#

链式赋值是指通过两个后续的索引操作来更新对象的技巧,例如:

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

foo 在列 bar 大于 5 的地方被更新。然而,这违反了 CoW 原则,因为它需要在一步中修改视图 df["foo"]df。因此,在使用 CoW 启用时,链式赋值将始终无法工作并引发 ChainedAssignmentError 警告。

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

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

在 copy on write 下,可以通过使用 loc 来完成。

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

只读 NumPy 数组#

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

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

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

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

如果 DataFrame 只由一个 NumPy 数组组成,则该数组与 DataFrame 共享数据。

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

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

此数组是只读的,这意味着它无法 inplace 修改。

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

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

ValueError: assignment destination is read-only

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

这里有两种潜在的解决方案:

  • 如果您想避免更新与您的数组共享内存的 DataFrame,请手动触发复制。

  • 使数组可写。这是一种更高效的解决方案,但会绕过 Copy-on-Write 规则,因此应谨慎使用。

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

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

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

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

应避免的模式#

当两个对象共享相同的数据并且您正在 inplace 修改其中一个对象时,不会执行防御性复制。

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

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

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

这会创建两个共享数据的对象,因此 setitem 操作将触发复制。如果初始对象 df 不再需要,则这不是必需的。只需将同一变量重新分配给它即可使对象持有的引用失效。

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

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

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

在此示例中无需复制。创建多个引用会使不必要的引用保持活动状态,因此会降低 Copy-on-Write 的性能。

Copy-on-Write 优化#

一种新的惰性复制机制,它将复制推迟到有问题对象被修改时,并且仅当该对象与另一个对象共享数据时才进行。此机制已添加到不需要复制底层数据的各种方法中。流行的例子是 DataFrame.drop() (对于 axis=1) 和 DataFrame.rename()

当启用 Copy-on-Write 时,这些方法会返回视图,这与常规执行相比提供了显著的性能改进。