PDEP-7: pandas 中一致的复制/视图语义,使用写时复制
- 创建日期:2021 年 7 月
- 状态:已实现
- 讨论:#36195
- 作者:Joris Van den Bossche
- 修订版:1
摘要
提案的简要概述
- 任何索引操作(以任何方式对 DataFrame 或 Series 进行子集,包括访问 DataFrame 列作为 Series)或任何返回新 DataFrame 或 Series 的方法的结果,在用户 API 方面始终表现得像副本。
- 我们实现了写时复制(作为实现细节)。这样,我们实际上可以在幕后尽可能多地使用视图,同时确保用户 API 的行为像副本。
- 因此,如果您想修改对象(DataFrame 或 Series),唯一的方法是直接修改该对象本身。
这解决了多个方面:1) 清晰一致的用户 API(一条明确的规则:任何子集或返回的系列/数据帧始终表现得像原始数据的副本,因此永远不会修改原始数据)和 2) 通过避免过度复制来提高性能(例如,链式方法工作流程不再在每个步骤都返回实际的数据副本)。
由于每个索引步骤都表现得像副本,这也意味着,使用此提案,“链式赋值”(具有多个 setitem 步骤)永远不会起作用,并且可以删除SettingWithCopyWarning
。
背景
pandas 当前关于索引是否返回视图或副本的行为令人困惑。即使对于经验丰富的用户来说,也很难判断将返回视图还是副本(请参阅以下摘要)。我们希望提供一个关于返回视图与副本的 API,该 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
此提案也扩展到方法
原则上,在防御性复制方面,索引没有什么特别之处。任何返回新系列/数据帧而不改变现有数据的方法(重命名、设置索引、分配、删除列等)目前默认返回副本,并且是返回视图的候选者
>>> 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
总之,修改只会在相同对象之间“传播”(不仅仅是相等(==
),而是相同(is
)在 python 术语中,请参见 docs)。传播并不是真正的正确术语,因为只有一个对象被修改了。
但是,当以某种方式创建一个新对象时(即使它可能是一个具有相同数据的 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 有“视图”的概念(一个与另一个数组共享数据的数组,查看相同的内存,例如,此解释了解更多详细信息)。通常,您会将视图创建为另一个数组的切片。但其他索引方法,通常称为“花式索引”,不会返回视图,而是返回副本:使用索引列表或布尔掩码。
Pandas 建立在 NumPy 之上,它使用这些概念,并将行为后果暴露给用户。这基本上意味着 Pandas 用户要了解索引的工作原理,还需要了解 NumPy 的视图/花式索引概念。
但是,由于 DataFrame 不是数组,因此在当前的 Pandas 中,复制/视图规则仍然不同于 NumPy 的规则。切片行通常会提供视图(遵循 NumPy),但切片列并不总是提供视图(这可以更改为匹配 NumPy,但是,请参见下面的“替代方案”1b)。花式索引行(例如,使用(位置)标签列表)会提供副本,但花式索引列 *可以* 提供视图(目前这也提供副本,但“替代方案”之一(1b)是始终返回视图)。
本文档中的建议是将 Pandas 面向用户的行为与这些 NumPy 概念分离。使用切片或掩码创建 DataFrame 的子集将以类似的方式对用户起作用(两者都返回一个新对象,并表现为原始对象的副本)。我们仍然在 Pandas 内部使用视图的概念来优化实现,但这对用户来说是隐藏的。
替代方案
该 原始文档 和 GitHub 问题 (索引操作中未来复制/视图语义的提案 - #36195) 讨论了几个使复制/视图情况更加一致和清晰的选项
-
定义明确的复制/视图规则:确保我们有更一致的规则来确定哪些操作会导致复制,哪些操作会导致视图,然后视图会导致父级发生变异,而副本不会。a. 最小的更改是将当前行为正式化。这归结为修复一些错误,并清楚地记录和测试哪些操作是视图,哪些操作是副本。b. 另一种选择是简化规则集。例如:选择列始终是视图,子集行始终是副本。或者:选择列始终是视图,子集行作为切片是视图,否则始终是副本。
-
写时复制: setitem 操作会检查它是否是对另一个 DataFrame 的视图。如果是,则会在修改之前复制我们的数据。(即本提案)
-
写时报错: setitem 操作会检查它是否是对另一个 DataFrame 的子集(视图或副本)。与视图情况下复制不同,我们会抛出一个异常,告诉用户要么使用
.copy_if_needed()
(名称待定)复制数据,要么使用.as_mutable_view()
(名称待定)将 DataFrame 标记为“可变视图”。
本文档基本上提出了对选项 2(写时复制)的扩展版本。以下是写时复制相对于其他选项的一些论点。
-
写时复制将提高方法(例如重命名、(重新)设置索引、删除列等,见上文)的复制/视图效率。这将导致更低的内存使用量和更好的性能。
-
本提案也可以被视为一个明确的“定义明确的规则”。在幕后使用写时复制是一种实现细节,可以将实际复制推迟到需要时。 “始终复制”规则是我们能得到的最简单的“定义明确的规则”。
上面提到的其他“定义明确的规则”想法将始终包含一些特定情况(以及与 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 当前返回视图(例如切片行)的用例中,以及在您对当前行为(即修改子集也会修改父数据帧)感到满意(或不在乎)的情况下,该提案将引入与当前行为相比的新副本。不过,有一个解决方法:如果先前的对象超出范围(例如,变量被重新分配给其他内容),则不需要复制。
向后兼容性
本文档中的提案显然是一个向后不兼容的更改,它会破坏现有行为。由于当前视图与副本以及变异之间存在不一致和细微差别,因此很难进行任何更改而不破坏更改。然而,当前的提案并不是更改最小的提案。无论如何,这样的更改都需要伴随一个主要版本升级(例如 pandas 3.0)。
进行一个在多个次要功能版本中存在的传统弃用周期会过于嘈杂。索引是一个过于常见的操作,无法包含警告(即使我们将其限制为以前返回视图的操作)。但是,此提案已经实现,因此可以使用。用户可以选择加入并测试他们的代码(从版本 1.5 开始,可以使用 pd.options.mode.copy_on_write = True
)。
此外,我们将为 pandas 2.2 添加一个警告模式,该模式会针对所有在写时复制提案下会改变行为的案例发出警告。我们可以提供一个清晰的文档升级路径,首先启用警告,修复所有警告,然后启用写时复制模式并确保您的代码仍然有效,最后升级到新的主要版本。
实现
该实现自 pandas 1.5(从 pandas 2.0 开始显著改进)以来一直可用。它使用弱引用来跟踪数据帧/序列的数据是否正在查看另一个(pandas)对象的数据,或者是否被另一个对象查看。这样,每当序列/数据帧被修改时,我们就可以检查它的数据是否需要先被复制,然后再进行修改(参见 这里)。
为了测试实现并尝试新的行为,您可以使用以下选项启用它
>>> 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 代码没有任何性能优势。
过滤后的数据帧
当前 SettingWithCopyWarning 变得烦人的一个典型示例是在过滤数据帧时(它总是已经返回一个副本)
>>> 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
如果您随后修改了过滤后的数据帧(例如,添加一列),您将收到不必要的 SettingWithCopyWarning(带有令人困惑的消息)。消除警告的唯一方法是进行防御性复制(df_filtered = df[df["A"] > 1].copy()
,这会导致在当前实现中复制数据两次,写时复制将不再需要 .copy()
)。
根据此提案,过滤后的数据帧永远不会是视图,上面的工作流程将按预期工作,不会发出警告(因此不需要额外的复制)。
修改 Series(来自数据帧列)
目前,将数据帧的列作为 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
。
这种情况类似于上面的“链式赋值”情况,只是使用了一个显式的中间变量。要实际更改原始数据帧,解决方案相同:在单个步骤中直接修改数据帧。例如
>>> 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 (评论)。
返回具有相同数据的新的 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 以及一些 pull 请求 (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 对象可以通过“复制-修改” (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 开始讨论关于明确的副本/视图语义的不同选项的原始文档 (google doc)。
相关邮件列表讨论:https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html