PDEP-7: pandas 中一致的复制/视图语义,使用写时复制

摘要

提案的简要概述

  1. 任何索引操作(以任何方式对 DataFrame 或 Series 进行子集,包括访问 DataFrame 列作为 Series)或任何返回新 DataFrame 或 Series 的方法的结果,在用户 API 方面始终表现得像副本。
  2. 我们实现了写时复制(作为实现细节)。这样,我们实际上可以在幕后尽可能多地使用视图,同时确保用户 API 的行为像副本。
  3. 因此,如果您想修改对象(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. 情况 1:我知道我的子集可能是原始数据的视图,并且我也想修改原始数据。
  2. 情况 2:我只想修改子集,而不修改原始数据。

如今,pandas 的不一致意味着这两种工作流程都无法实现。第一种很难实现,因为索引操作通常(尽管并非总是)返回副本,即使返回视图,在进行修改时有时也会收到SettingWithCopyWarning警告。第二种在某种程度上可行,但需要进行许多防御性复制(以避免SettingWithCopyWarning警告,或确保在返回视图时拥有副本)。

提案

出于这些原因(一致性、性能、代码清晰度),本 PDEP 提出以下更改

  1. 任何索引操作(以任何方式对 DataFrame 或 Series 进行子集,包括访问 DataFrame 列作为 Series)或任何返回新 DataFrame 或 Series 的方法的结果,在用户 API 方面始终表现得像副本。
  2. 我们实现写时复制。这样,我们就可以在幕后尽可能多地使用视图,同时确保用户 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 用户不会期望 df2df3 是一个视图,这样修改 df2df3 就会修改 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

虽然我们现在有两个变量(df1df2),但此赋值遵循标准的 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) 讨论了几个使复制/视图情况更加一致和清晰的选项

  1. 定义明确的复制/视图规则:确保我们有更一致的规则来确定哪些操作会导致复制,哪些操作会导致视图,然后视图会导致父级发生变异,而副本不会。a. 最小的更改是将当前行为正式化。这归结为修复一些错误,并清楚地记录和测试哪些操作是视图,哪些操作是副本。b. 另一种选择是简化规则集。例如:选择列始终是视图,子集行始终是副本。或者:选择列始终是视图,子集行作为切片是视图,否则始终是副本。

  2. 写时复制: setitem 操作会检查它是否是对另一个 DataFrame 的视图。如果是,则会在修改之前复制我们的数据。(即本提案)

  3. 写时报错: setitem 操作会检查它是否是对另一个 DataFrame 的子集(视图或副本)。与视图情况下复制不同,我们会抛出一个异常,告诉用户要么使用 .copy_if_needed()(名称待定)复制数据,要么使用 .as_mutable_view()(名称待定)将 DataFrame 标记为“可变视图”。

本文档基本上提出了对选项 2(写时复制)的扩展版本。以下是写时复制相对于其他选项的一些论点。

上面提到的其他“定义明确的规则”想法将始终包含一些特定情况(以及与 NumPy 规则的偏差)。即使有明确的规则,用户仍然需要了解这些规则的细节才能理解 df['a'][df['b'] < 0] = 0df[df['b'] < 0]['a'] = 0 的不同之处(切换列/行索引的顺序:第一个会修改 df(如果选择列是视图),而第二个不会)。而使用写时复制的“始终复制”规则,这两个示例都不会更新 df

另一方面,本文档中的提案并没有让用户控制子集是否应该是一个视图(如果可能),当被修改时会修改父级。修改父 DataFrame 的唯一方法是对该 DataFrame 本身进行直接索引操作。

请参阅 GitHub 评论,其中包含更详细的论证:https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449

缺点

除了本提案会导致行为上的向后不兼容的重大更改(见下一节)之外,还有一些其他潜在的缺点。

向后兼容性

本文档中的提案显然是一个向后不兼容的更改,它会破坏现有行为。由于当前视图与副本以及变异之间存在不一致和细微差别,因此很难进行任何更改而不破坏更改。然而,当前的提案并不是更改最小的提案。无论如何,这样的更改都需要伴随一个主要版本升级(例如 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)不会修改其构造的数据(在自身被修改时),从而遵循提议的规则。

更多背景:视图与副本的当前行为

据我们所知,索引操作目前在以下情况下返回视图

在实践中,剩余的操作(使用列表索引器或布尔掩码对行进行子集)会返回一个副本,当用户尝试修改子集时,我们将引发 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 历史

注意:本提案在转换为 PDEP 之前已经讨论过。主要讨论发生在 GH-36195 中。本文档改编自 Tom Augspurger 开始讨论关于明确的副本/视图语义的不同选项的原始文档 (google doc)。

相关邮件列表讨论:https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html