pandas中的SettingWithCopyWarning和Copy-on-Write
本文仅探讨 pandas 版本 <3.0.0 的情况。
事情的起因
事情的起因是一段简单的代码:
1 |
|
1 |
|
这段代码构造了一个带多层索引(MultiIndex)的 DataFrame,从中选取了一个子集,并且在该子集上新增了一列。虽然代码返回了正确的结果,但同时也发出了 SettingWithCopyWarning
警告。这是什么?该如何解决?
视图(View)与副本(Copy)
要解释 SettingWithCopyWarning
警告,首先需要了解 pandas 中视图(View)和副本(Copy)的概念。
在 pandas 中,大多数操作都是以一个原始对象(如 DataFrame 或 Series)为输入,并返回一个新的对象。这个返回的新对象有两种存在形式:
- 视图(View):类似于 MySQL 中的视图(VIEW),它是以不同形式展示同一数据的“窗口”。它直接引用了原始对象的底层数据,共享了内存中的数据。因此,对视图的修改会直接作用于原始对象,反之亦然。
- 副本(Copy):复制了原始对象的底层数据,在内存中独立存在。因此,对副本的修改不会影响原始对象,反之亦然。
至于某个操作会返回视图还是副本,主要取决于底层对象的内存布局。由于 pandas 无法对底层数据的内存布局做出严格保证,这一行为往往难以预测(参见官方文档)。
SettingWithCopyWarning
SettingWithCopyWarning
警告就是用来提示这种不确定性带来的潜在问题的。它相当于在提醒你:连续操作的中间返回值是副本而不是视图。这意味着你的修改不会作用于原始对象——如果你本来是想改原始对象,那结果就会和预期不符。
以文章开篇的代码为例,df.xs()
所返回的 subset
是 df
的副本,因此任何对于 subset
的操作是无法作用于 df
的。虽然在这里,我并不需要改变 df
,但 pandas 对此显然无从得知,还是发出了 SettingWithCopyWarning
警告。
即便 subset
是 df
的视图,给 subset
增加一列的操作能否同步到 df
上也是存在疑问的。
如果不想看到 SettingWithCopyWarning
警告,有以下两个选项:
可以更改 pandas 的
mode.chained_assignment
选项:1
2
3
4
5
6
7
8
9
10
11import pandas as pd
pd.options.mode.chained_assignment = None
df = pd.DataFrame([[1, 2, 3, 4],
[5, 6, 7, 8]],
columns=pd.MultiIndex.from_product([["foo", "bar"],
["odd", "even"]]))
subset = df.xs("odd", axis="columns", level=1)
subset["baz"] = subset["foo"] + subset["bar"]
print(subset)1
2
3foo bar baz
0 1 3 4
1 5 7 12可以看到这并不影响代码的结果。
pandas 的
mode.chained_assignment
选项可以取以下值:None
:既不发出警告,也不抛出错误'warn'
:默认值,pandas 会发出SettingWithCopyWarning
警告'raise'
:pandas 会抛出SettingWithCopyError
错误
虽然这个选项名字里提到了“链式赋值”(下文会介绍),但它实际上控制的范围更广,并不只有链式赋值,也包括了文章开头的示例中的情况。
可以在
df.xs()
后面增加一个显式的.copy()
:1
2
3
4
5
6
7
8
9
10
11import pandas as pd
pd.options.mode.chained_assignment = None
df = pd.DataFrame([[1, 2, 3, 4],
[5, 6, 7, 8]],
columns=pd.MultiIndex.from_product([["foo", "bar"],
["odd", "even"]]))
subset = df.xs("odd", axis="columns", level=1).copy()
subset["baz"] = subset["foo"] + subset["bar"]
print(subset)1
2
3foo bar baz
0 1 3 4
1 5 7 12可以看到这并不影响代码的结果。
新增的
.copy()
相当于告诉 pandas:我不希望subset
成为df
的视图,也不希望通过修改subset
来修改df
。pandas 也因此不会发出SettingWithCopyWarning
警告了。不过,这么做可能会带来不必要的性能浪费。
链式赋值(Chained Assignment)
文章开头的示例中的连续操作可能并非最常见,一种更为常见的连续操作是“链式赋值”。
所谓链式赋值,指的是通过连续索引的方式对 pandas 对象进行修改,例如:
1 |
|
1 |
|
可以看到代码对 df
进行了修改,但也发出了 ChainAssignmentError
警告。
ChainedAssignmentError
有了前面的铺垫,就很好理解 ChainedAssignmentError
警告了。
由于 df["foo"]
返回了一个 df
的视图,对于 df["foo"]
的修改得以作用在 df
上。但是 df["foo"]
返回的是 df
的视图还是副本,取决于底层对象的内存布局,是不确定的。假如,df["foo"]
返回的是 df
的副本,那代码就根本无法产生预期的结果。ChainedAssignmentError
警告就是在提醒这种“危险”的用法。
如果不想看到 ChainedAssignmentError
警告,pandas 官方文档建议将链式赋值改成 .loc
/.iloc
。由于 .loc
/.iloc
是单个操作,不依赖于连续操作的中间返回值是视图还是副本,使用 .loc
/.iloc
不仅可以避免 ChainedAssignmentError
警告,还可以防止代码产生预料之外的结果。例如:
1 |
|
1 |
|
可以看到这并不影响代码的结果。
写时复制(Copy-on-Write)
最后,我们来讲讲“写时复制(Copy-on-Write)”。这是一个默认关闭的选项(在 pandas >= 3.0.0 中将默认开启)。开启“写时复制”后,那些通过返回视图也能达成目的、其实不需要复制底层数据的操作(如 DataFrame.drop(axis=1)
)将启用“懒复制”机制——先是返回一个“视图”,等到返回值要被写入时,再执行底层数据的复制,将返回值转变成一个“副本”。这么做的好处有:
- 提升性能:原本总是进行的底层数据的复制,现在只会在必要时发生了。
- 减少错误:由于在写入时,返回值总是会转变成“副本”,每次写入最多只会影响一个对象,不需要担心不小心修改了多个对象了。
- 减少心智负担:之前各种操作的返回值可能是“视图”也有可能是“副本”。现在它们要么直接返回“副本”,要么先返回“视图”、再在写入时将其转变成“副本”——即,你所写入的永远是“副本”。
启用“写时复制”选项会对本文中的示例产生怎样的影响呢?
第一个示例由于并不依赖中间返回值来修改原始对象,可以看到这并不影响代码的结果:
1
2
3
4
5
6
7
8
9
10
11import pandas as pd
pd.options.mode.copy_on_write = True
df = pd.DataFrame([[1, 2, 3, 4],
[5, 6, 7, 8]],
columns=pd.MultiIndex.from_product([["foo", "bar"],
["odd", "even"]]))
subset = df.xs("odd", axis="columns", level=1).copy()
subset["baz"] = subset["foo"] + subset["bar"]
print(subset)1
2
3foo bar baz
0 1 3 4
1 5 7 12第二个示例由于需要中间返回值来修改原始对象,启用“写时复制”直接改变了代码原有的行为:
1
2
3
4
5
6
7
8
9import pandas as pd
pd.options.mode.copy_on_write = True
df = pd.DataFrame([[1, 2],
[3, 4]],
columns=["foo", "bar"])
df["foo"][0] = 100
print(df)1
2
3
4
5
6
7
8
9
10
11foo bar
0 1 2
1 3 4
/var/folders/68/cck66_g12kq8sg83lbjw6nf40000gn/T/ipykernel_25093/1198405199.py:8: ChainedAssignmentError: A value is trying to be set on a copy of a DataFrame or Series through chained assignment.
When using the Copy-on-Write mode, such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy.
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
df["foo"][0] = 100
pandas 的 mode.copy_on_write
选项可以取以下值:
False
:不开启“写时复制”(pandas < 3.0.0 的默认值)'warn'
:不开启“写时复制”,但对于每一个会因“写时复制”的启用而改变行为的操作,pandas 都会发出警告True
:开启“写时复制”(pandas >= 3.0.0 的默认值)