PDEP-7:在 pandas 中使用写时复制实现一致的复制/视图语义
- 创建日期:2021 年 7 月
- 状态:已实现
- 讨论:#36195
- 作者:Joris Van den Bossche
- 修订版本:1
摘要
提案简要概述
- 任何索引操作(以任何方式对 DataFrame 或 Series 进行子集操作,即包括将 DataFrame 列作为 Series 访问)或任何返回新 DataFrame 或 Series 的方法的结果,在用户 API 层面都始终表现得如同是原对象的副本。
- 我们实现了写时复制(作为实现细节)。通过这种方式,我们可以在底层尽可能多地使用视图,同时确保用户 API 表现得如同是副本。
- 因此,如果您想修改一个对象(DataFrame 或 Series),唯一的方法是直接修改该对象本身。
这解决了多个方面的问题:1) 提供清晰一致的用户 API(一个明确的规则:任何子集或返回的 Series/DataFrame 总是表现得如同是原对象的副本,因此永远不会修改原对象),以及 2) 通过避免不必要的复制来提高性能(例如,链式方法调用工作流不再在每一步都返回实际的数据副本)。
由于每个独立的索引步骤都表现得如同是副本,这意味着根据本提案,“链式赋值”(包含多个 setitem 步骤)将永远不会生效,并且可以移除 SettingWithCopyWarning
。
背景
当前 pandas 在索引操作是返回视图还是副本方面的行为令人困惑。即使对于经验丰富的用户,也很难判断是返回视图还是副本(参见下文摘要)。我们希望提供一个在返回视图与副本方面一致且合理的 API。
我们也关注性能。从索引操作返回视图更快且减少内存使用。对于许多不修改数据的方法(例如设置/重置索引、重命名列等)也是如此,这些方法可以在链式方法调用工作流中使用,并且目前在每一步都返回一个新副本。
最后,视图存在 API / 可用性问题。在修改 DataFrame 子集(列和/或行选择)的操作中,很难判断用户的意图,例如:
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1
当用户修改 df2
时,他们是打算修改 df
吗(暂不考虑当前实现的问题)?换句话说,如果在一个完全一致的世界中,列索引总是返回视图或总是返回副本,上面的代码是否意味着用户想要修改 df
?
用户可能有两种可能的意图行为:
- 情况 1:我知道我的子集可能是原对象的视图,并且我想同时修改原对象。
- 情况 2:我只想修改子集而不修改原对象。
目前,pandas 的不一致性意味着这两种工作流都无法真正实现。第一种很困难,因为索引操作经常(但并非总是)返回副本,即使返回了视图,修改时有时也会收到 SettingWithCopyWarning
。第二种在某种程度上是可能的,但需要大量的防御性复制(以避免 SettingWithCopyWarning
,或确保在返回视图时您拥有一个副本)。
提案
出于这些原因(一致性、性能、代码清晰度),本 PDEP 提出以下更改:
- 任何索引操作(以任何方式对 DataFrame 或 Series 进行子集操作,即包括将 DataFrame 列作为 Series 访问)或任何返回新 DataFrame 或 Series 的方法的结果,在用户 API 层面都始终表现得如同是原对象的副本。
- 我们实现了写时复制。通过这种方式,我们可以在底层尽可能多地使用视图,同时确保用户 API 表现得如同是副本。
目的是尽可能利用视图的性能优势,同时为用户提供一致且清晰的行为。这实际上使得返回视图成为一种内部优化,用户无需知道特定的索引操作是返回视图还是副本。新的规则很简单:任何通过索引操作或方法从另一个 Series/DataFrame 派生出来的 Series/DataFrame,总是表现得如同是原 Series/DataFrame 的副本。
确保这种一致行为的机制,即写时复制,将包含以下内容:setitem 操作(即 df[..] = ..
或 df.loc[..] = ..
或 df.iloc[..] = ..
,或 Series 的等效操作)将检查正在修改的数据是否是另一个 DataFrame 的视图(或正在被另一个 DataFrame 查看)。如果是,我们将在修改之前复制数据。
以前面的例子来说,如果用户不希望修改父对象,我们不再需要仅仅为了避免 SettingWithCopyWarning
而进行防御性复制。
# Case 2: The user does not want mutating df2 to mutate the parent df, via CoW
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1
>>> df.iloc[1, 0] # df was not mutated
2
另一方面,如果用户确实想修改原始 df
,他们不能再依赖于 df2
可能是视图的事实,因为现在修改子集永远不会修改父对象。修改原始 df
的唯一方法是将所有索引步骤合并为对原始对象的一次索引操作(没有“链式”setitem)
# Case 1: user wants mutations of df2 to be reflected in df -> no longer possible
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df[["A", "B"]]
>>> df2.loc[df2["A"] > 1, "A"] = 1 # mutating df2 will not mutate df
>>> df.loc[df["A"] > 1, "A"] = 1 # need to directly mutate df instead
本提案也适用于方法
原则上,在防御性复制方面,索引操作没有什么特别之处。任何返回新的 Series/DataFrame 但不改变现有数据的方法(rename、set_index、assign、删除列等)目前默认返回一个副本,并且是返回视图的候选方法。
>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")
现在,通常情况下,pandas 用户不会期望 df2
或 df3
是视图,以至于修改 df2
或 df3
会修改 df
。写时复制也使我们能够避免上述方法(或使用方法链的变体,例如 df.rename(columns=str.lower).set_index("a")
)中的不必要复制。
向前传播修改
到目前为止,我们已经考虑了取子集、修改子集以及这应该如何影响父对象(更常见的情况)。那么另一个方向呢,即父对象被修改了怎么办?
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df[["A"]]
>>> df.iloc[0, 0] = 10
>>> df2.iloc[0, 0] # what is this value?
考虑到根据本提案,df2
被视为是 df
的副本(即行为上像副本),修改父对象 df
也不会修改子集 df2
。
修改何时会传播到其他对象,何时不会?
本提案基本意味着修改永远不会传播到其他对象(视图会发生这种情况)。修改 DataFrame 或 Series 的唯一方法是直接修改对象本身。
但我们用 Python 的术语来说明一下。假设我们有一个 DataFrame df1
,然后将其赋值给另一个名称 df2
:
>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1
尽管我们现在有两个变量(df1
和 df2
),这个赋值遵循标准的 Python 语义,并且两个名称都指向同一个对象(“df1 和 df2 是同一对象”)。
>>> id(df1) == id(df2) # or: df1 is df2
True
因此,如果您修改 DataFrame df2
,这也将反映在另一个变量 df1
中,反之亦然(因为它们是同一个对象)。
>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0]
10
总之,修改只在同一对象之间“传播”(不仅仅是相等(==
),而是 Python 术语中的同一对象(is
),参见文档)。“传播”并不是真正恰当的术语,因为被修改的只有一个对象。
然而,当以某种方式创建一个新对象时(即使它可能是一个数据相同的 DataFrame,因此是一个“相等”的 DataFrame):
>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
>>> df2 = df1[:] # or df1.loc[...] with some indexer
这些对象不再是同一对象。
>>> id(df1) == id(df2) # or df1 is df2
False
因此,对其中一个的修改不会传播到另一个。
>>> df1.iloc[0, 0]
1
>>> df2.iloc[0, 0] = 10
>>> df1.iloc[0, 0] # not changed
1
当前,任何 getitem 索引操作都返回新的对象,并且几乎所有 DataFrame/Series 方法都返回一个新的对象(某些情况下除了带有 inplace=True
的方法),因此遵循上述逻辑,即永远不会修改其父/子 DataFrame 或 Series(尽可能使用惰性写时复制机制)。
NumPy 与 pandas 中的复制/视图行为
NumPy 有“视图”的概念(一个与另一个数组共享数据、查看相同内存的数组,更多详情请参见例如此解释)。通常,您通过对另一个数组进行切片来创建视图。但其他索引方法,通常称为“花式索引”(fancy indexing),返回的不是视图而是副本:使用索引列表或布尔掩码。
Pandas 是基于 NumPy 构建的,使用了这些概念,并将其行为结果暴露给用户。这基本上意味着 pandas 用户为了理解索引工作原理的细节,还需要理解 NumPy 的视图/花式索引概念。
然而,由于 DataFrame 不是数组,当前 pandas 中的复制/视图规则仍然与 NumPy 的规则不同。行切片通常返回视图(遵循 NumPy),但列切片并不总是返回视图(但这可以更改以匹配 NumPy,参见下文“替代方案”1b)。花式索引行(例如,使用位置标签列表)返回副本,但花式索引列可能返回视图(目前这也返回副本,但“替代方案”之一(1b)是让它总是返回视图)。
本文档中的提案是将 pandas 面向用户的行为与那些 NumPy 概念解耦。使用切片或掩码创建 DataFrame 子集对用户来说行为类似(两者都返回一个新对象并表现得如同是原对象的副本)。我们仍然在 pandas 内部使用视图概念来优化实现,但这对于用户来说是隐藏的。
替代方案
原始文档和 GitHub issue (索引操作中未来复制/视图语义的提案 - #36195) 讨论了几种使复制/视图情况更一致和清晰的方案:
-
明确定义的复制/视图规则:确保我们在哪些操作导致复制、哪些操作导致视图方面有更一致的规则,然后视图会导致修改父对象,而副本不会。a. 最小的改动是将当前行为正式化。这归结为修复一些 bug 并清楚地文档化和测试哪些操作是视图,哪些是副本。b. 另一种选择是简化规则集。例如:选择列总是视图,子集行总是副本。或者:选择列总是视图,将行作为切片进行子集是视图,否则总是副本。
-
写时复制: setitem 操作会检查它是否是另一个 DataFrame 的视图。如果是,我们将在修改之前复制数据。(即本提案)
-
写时报错: setitem 操作会检查它是否是另一个 DataFrame 的子集(无论是视图还是副本)。不同之处在于,如果它是视图,我们将引发异常,告诉用户要么使用
.copy_if_needed()
(名称待定)复制数据,要么使用.as_mutable_view()
(名称待定)将该 frame 标记为“可变视图”。
本文档基本上提出了选项 2(写时复制)的扩展版本。与其它选项相比,支持写时复制的一些论据如下:
-
写时复制将提高方法(例如 rename、(re)set_index、drop columns 等。参见上文)的复制/视图效率。这将带来更低的内存使用和更好的性能。
-
本提案也可以被视为一个清晰的“明确定义的规则”。在底层使用写时复制是一种实现细节,用于将实际复制延迟到需要时才进行。“总是复制”的规则是我们能得到的“明确定义的规则”中最简单的一个。
上述其他“明确定义的规则”想法总是会包含一些特定的情况(以及偏离 NumPy 规则)。即使有明确的规则,用户仍然需要了解这些规则的细节,才能理解 df['a'][df['b'] < 0] = 0
或 df[df['b'] < 0]['a'] = 0
执行的操作有所不同(列/行索引的顺序切换:第一个会修改 df
(如果选择列是视图),第二个则不会)。而采用写时复制的“总是复制”规则,这两种示例都不会更新 df
。
另一方面,本文件中的提案并没有赋予用户控制子集是否应成为视图(在可能的情况下)的权力,以便在修改子集时修改父对象。修改父 DataFrame 的唯一方法是直接对该 DataFrame 本身进行索引操作。
有关更详细的论证,请参阅 GitHub 评论:https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449
缺点
除了本提案会导致向后不兼容、行为上的重大改变之外(参见下一节),还有一些其他潜在的缺点:
- 偏离 NumPy:NumPy 使用复制和视图的概念,而本提案中视图在 pandas 中基本上不再存在(至少对用户而言;我们仍然会将其作为实现细节在内部使用)
- 但作为反驳论点:许多 pandas 用户可能不熟悉这些概念,而且 pandas 已经偏离了 NumPy 的精确规则。
- 索引和方法的性能成本变得更难预测:因为数据的复制不是发生在实际创建新对象的时候,而是可能发生在稍后修改父对象或子对象时,这使得 pandas 何时复制数据变得不那么透明(但总的来说,我们应该复制的次数会更少)。这一点在一定程度上得到了缓解,因为写时复制只会复制被修改的列。不相关的列不会被复制。
- 在某些用例中内存使用量增加:尽管大多数用例会看到本提案带来的内存使用量改进,但在少数用例中可能并非如此。特别是在 pandas 当前返回视图(例如行切片)的情况下,以及在您对当前行为(即修改切片子集也会修改父 DataFrame)感到满意(或不关心)的情况下,本提案相比当前行为会引入新的复制。不过,这一点有一个变通方法:如果先前的对象超出作用域(例如,变量被重新赋值给其他内容),则不需要复制。
向后兼容性
本文档中的提案显然是向后不兼容的更改,会破坏现有行为。由于当前视图与副本以及修改方面的不一致和微妙之处,很难在不引入重大更改的情况下进行任何改动。不过,当前提案并非改动最小的提案。这样的改动无论如何都需要伴随主版本号的提升(例如 pandas 3.0)。
进行一个包含多个小版本功能发布的传统弃用周期会产生太多噪音。索引操作太常见了,不适合包含警告(即使我们将其仅限于以前返回视图的操作)。然而,本提案已经实现并可用。用户可以选择加入并测试他们的代码(从 1.5 版本开始可以通过设置 pd.options.mode.copy_on_write = True
实现)。
此外,我们将在 pandas 2.2 中添加一个警告模式,针对在写时复制提案下行为会改变的所有情况发出警告。我们可以提供一个清晰文档化的升级路径:首先启用警告,修复所有警告,然后启用写时复制模式并确保您的代码仍然正常工作,最后升级到新的主要版本。
实现
该实现自 pandas 1.5 起可用(从 pandas 2.0 起显著改进)。它使用弱引用(weakrefs)来跟踪 DataFrame/Series 的数据是否正在查看另一个(pandas)对象的数据,或者是否正在被另一个对象查看。通过这种方式,无论何时 Series/DataFrame 被修改,我们都可以检查其数据在修改之前是否需要先被复制(参见此处)。
要测试该实现并体验新行为,您可以使用以下选项启用它:
>>> pd.options.mode.copy_on_write = True
在导入 pandas 之后(或者在导入 pandas 之前设置环境变量 PANDAS_COPY_ON_WRITE=1
)。
具体示例
链式赋值
考虑一个“经典”的链式索引案例,这是 SettingWithCopy 警告的最初动机:
>>> df[df['B'] > 3]['B'] = 10
这大致等同于
>>> df2 = df[df['B'] > 3] # Copy under NumPy's rules
>>> df2['B'] = 10 # Update (the copy) df2, df not changed
>>> del df2 # All references to df2 are lost, goes out of scope
因此 df
没有被修改。正是出于这个原因,引入了 SettingWithCopyWarning。
根据本提案,任何索引操作的结果都表现得如同是副本(写时复制),因此链式赋值将永远不会生效。鉴于此后不再存在歧义,其想法是取消该警告。
上面的例子是在当前 pandas 中链式赋值不起作用的一个案例。但当然也存在当前确实起作用并被使用的链式赋值模式。根据本提案,任何链式赋值都不会生效,因此那些情况将停止工作(例如上面例子但切换顺序)。
>>> df['B'][df['B'] > 3] = 10
# or
>>> df['B'][0:5] = 10
这些情况将引发 ChainedAssignmentError
警告,因为它们永远无法实现用户意图。当这些操作从 Cython 触发时,会出现假阳性情况,因为 Cython 使用不同的引用计数机制。这些情况应该很少见,因为从 Cython 调用 pandas 代码没有任何性能优势。
过滤后的 DataFrame
当前 SettingWithCopyWarning 变得烦人的一个典型例子是过滤 DataFrame(过滤本身总是返回一个副本):
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df_filtered = df[df["A"] > 1]
>>> df_filtered["new_column"] = 1
SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
如果您随后修改过滤后的 DataFrame(例如添加一列),您会收到不必要的 SettingWithCopyWarning(消息令人困惑)。摆脱警告的唯一方法是进行防御性复制(df_filtered = df[df["A"] > 1].copy()
),这在当前实现中会导致数据被复制两次,而写时复制将不再需要 .copy()
)。
根据本提案,过滤后的 DataFrame 永远不是视图,上述工作流将按预期工作,不会发出警告(因此无需额外的复制)。
修改 Series(从 DataFrame 列获取)
当前,将 DataFrame 的列作为 Series 访问是少数几个实际保证始终是视图的情况之一:
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> s = df["A"]
>>> s.loc[0] = 0 # will also modify df (but no longer with this proposal)
根据本提案,任何索引操作都会导致复制,因此将列作为 Series 访问也是如此(实际上,它当然仍然是视图,但通过写时复制会表现得像副本)。在上面的例子中,修改 s
将不再修改父对象 df
。
这种情况类似于上面的“链式赋值”案例,只是多了一个显式的中间变量。要实际更改原始 DataFrame,解决方案是相同的:一步到位直接修改 DataFrame。例如:
>>> df.loc[0, "A"] = 0
“浅”复制
当前,可以使用 copy(deep=False)
创建 DataFrame 的“浅”复制。这会创建一个新的 DataFrame 对象,但不会复制底层的索引和数据。对原始数据进行的任何更改都会反映在浅复制中(反之亦然)。参见文档。
>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]})
>>> df2 = df.copy(deep=False)
>>> df2.iloc[0, 0] = 0 # will also modify df (but no longer with this proposal)
根据本提案,这种浅复制不再可能。只有“同一”对象(在 Python 术语中:df2 is df
)才能共享数据而不会触发写时复制。浅复制将通过写时复制变成一种“延迟”复制。
有关此事的更详细评论,请参阅 #36195 (comment)。
返回包含相同数据的新 DataFrame 的方法
这个例子上面也展示过了,但是所以 当前 几乎所有 Series/DataFrame 的方法默认都返回一个新对象,它是原始数据的副本:
>>> df2 = df.rename(columns=str.lower)
>>> df3 = df2.set_index("a")
在上面的例子中,df2 持有 df 数据的副本,df3 持有 df2 数据的副本。修改其中任何一个 DataFrame 都不会修改父 DataFrame。
根据本提案,这些方法将继续返回新对象,但会使用带写时复制的浅复制机制,这样实际上这些方法不需要在每一步都复制数据,同时保留当前行为。
Series 和 DataFrame 构造函数
当前,Series 和 DataFrame 构造函数并不总是复制输入(取决于输入的类型)。例如:
>>> s = pd.Series([1, 2, 3])
>>> s2 = pd.Series(s)
>>> s2.iloc[0] = 0 # will also modify the parent Series s
>>> s
0 0 # <-- modified
1 2
2 3
dtype: int64
根据本提案,我们也可以在构造函数中默认使用带写时复制的浅复制方法。这意味着默认情况下,一个新的 Series 或 DataFrame(如上面例子中的 s2
)在被修改时,不会修改从中构建的数据,从而遵循本提案的规则。
更多背景:视图与复制的当前行为
据我们所知,索引操作在以下情况下当前返回视图:
- 从 DataFrame 中选择单列(作为 Series)总是视图(
df['a']
)。 - 从 DataFrame 中切片列以创建子集 DataFrame(
df[['a':'b']]
或df.loc[:, 'a': 'b']
)是视图,如果原始 DataFrame 由单个块组成(单一 dtype,合并),并且如果您进行的是切片(而非列表选择)。在所有其他情况下,获取子集总是副本。 - 当行索引器是
slice
对象时,选择行可能返回视图。
剩余的操作(使用列表索引器或布尔掩码对行进行子集操作)实际上返回副本,当用户尝试修改子集时,我们将引发 SettingWithCopyWarning。
更多背景:先前的尝试
我们之前讨论过这个普遍性问题。https://github.com/pandas-dev/pandas/issues/10954 和一些拉取请求 (https://github.com/pandas-dev/pandas/pull/12036, https://github.com/pandas-dev/pandas/pull/11207, https://github.com/pandas-dev/pandas/pull/11500)。
与其他语言/库的比较
R
对用户而言,R 的行为有些类似。大多数 R 对象可以被视为不可变的,通过“copy-on-modify”(写时复制/修改,https://adv-r.hadley.nz/names-values.html#copy-on-modify)。但与 Python 不同的是,在 R 中,这是一种语言特性,任何赋值(将变量绑定到新名称)或作为函数参数传递本质上都会创建一个“副本”(当修改此类对象时,此时实际数据被复制并重新绑定到名称)。
x <- c(1, 2, 3)
y <- x
y[[1]] <- 10 # does not modify x
而如果您在 Python 中使用列表执行上述示例,x 和 y 是“同一对象”,修改其中一个也会修改另一个。
作为这种语言行为的结果,修改一个 data.frame
不会修改可能共享内存的其他 data.frames(在“写时复制/修改”之前)。
Polars
Polars (https://github.com/pola-rs/polars) 是一个带有 Python 接口的 DataFrame 库,主要用 Rust 构建在 Arrow 之上。它明确提及“写时复制”语义是其特性之一。
根据一些实验,Polars 面向用户的行为似乎与本提案中描述的行为相似(修改 DataFrame/Series 永远不会修改父/子对象,因此链式赋值也无效)。
PDEP-7 历史
- 2021 年 7 月:初始版本
- 2023 年 2 月:转换为 PDEP
注:本提案在转换为 PDEP 之前已经过讨论。主要讨论发生在 GH-36195。本文档修改自 Tom Augspurger 开始的、讨论清晰的复制/视图语义不同选项的原始文档(谷歌文档)。
相关邮件列表讨论:https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html