写时复制 (CoW)#
备注
写时复制现在是 pandas 3.0 的默认设置。
写时复制首次在1.5.0版本中引入。从2.0版本开始,通过写时复制实现的大部分优化都已实现并得到支持。从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
。具体行为难以预测。写时复制解决了意外修改多个对象的问题,它明确禁止了这一点。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
以下部分将解释这意味着什么以及它如何影响现有应用程序。
迁移到写时复制#
写时复制是 pandas 3.0 中的默认且唯一模式。这意味着用户需要迁移他们的代码以符合写时复制规则。
在 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 的情况下更新了 df
和 subset
:
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
。如果需要此行为,可以将此语句重写为单个语句,使用 loc
或 iloc
。DataFrame.where()
是这种情况下的另一个合适替代方案。
使用 DataFrame
中的 inplace 方法更新选定的列将不再有效。
In [7]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
In [8]: df["foo"].replace(1, 5, inplace=True)
In [9]: df
Out[9]:
foo bar
0 1 4
1 2 5
2 3 6
这是另一种链式赋值形式。这通常可以重写为两种不同的形式:
In [10]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
In [11]: df.replace({"foo": {1: 5}}, inplace=True)
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 数组。这一更改是为了避免在 pandas 之外就地更改 NumPy 数组时突变 pandas 对象。您可以设置 copy=False
以避免此复制。
描述#
CoW 意味着任何以任何方式从另一个 DataFrame 或 Series 派生的对象总是表现得像一个副本。因此,我们只能通过修改对象本身来改变其值。CoW 不允许就地更新与另一个 DataFrame 或 Series 对象共享数据的 DataFrame 或 Series。
这避免了在修改值时产生副作用,因此,大多数方法可以避免实际复制数据,只在必要时触发复制。
以下示例将在原地操作:
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 复制数据。由于 df
和 df2
两个对象共享相同的数据,修改 df2
时会触发复制。对象 df
仍然具有与最初相同的值,而 df2
已被修改。
如果在执行 reset_index
操作后不再需要对象 df
,可以通过将 reset_index
的输出分配给同一个变量来模拟类似就地操作的效果:
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
不与其他任何对象共享数据。修改对象时不需要复制。这通常适用于 写时复制优化 中列出的所有方法。
之前,在操作视图时,视图和父对象被修改:
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 100 4
1 2 5
2 3 6
列 foo
在列 bar
大于 5 时被更新。然而,这违反了 CoW 原则,因为它需要在一部中修改视图 df["foo"]
和 df
。因此,链式赋值将始终无法工作并引发一个 ChainedAssignmentError
警告,当启用 CoW 时:
In [33]: df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
In [34]: df["foo"][df["bar"] > 5] = 100
通过写时复制,这可以通过使用 loc
来完成。
In [35]: df.loc[df["bar"] > 5, "foo"] = 100
只读 NumPy 数组#
访问 DataFrame 的底层 NumPy 数组将返回一个只读数组,如果该数组与初始 DataFrame 共享数据:
如果初始 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]])
这个数组是只读的,这意味着它不能就地修改:
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,可以手动触发复制。
使数组可写。这是一个更高效的解决方案,但绕过了写时复制规则,因此应谨慎使用。
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]])
要避免的模式#
如果在修改一个对象时,两个对象共享相同的数据,则不会执行防御性复制。
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
在这个例子中不需要复制。创建多个引用会使不必要的引用保持活动状态,从而在使用写时复制时会损害性能。
写时复制优化#
一个新的惰性复制机制,该机制将复制推迟到被修改的对象,并且仅当该对象与其他对象共享数据时才进行复制。此机制已添加到不需要底层数据副本的方法中。流行的例子是 axis=1
的 DataFrame.drop()
和 DataFrame.rename()
。
这些方法在启用写时复制时返回视图,与常规执行相比,这提供了显著的性能提升。