在Numpy和Pandas中,有两个重要概念,容易混淆,一个是浅拷贝,也称为视图,另外一个是深拷贝,或者就称为拷贝。如果操作不当,Pandas会爆出SettingWithCopyWarning
的异常。
本文我将就视图和拷贝问题,结合异常进行总结。
本文的操作,是基于Python3.7及其以上版本,并且Numpy使用的是1.18版本,Pandas的版本号是1.0,其他在此之上的版本一般都能兼容。
至于Pandas和Numpy的安装方法,请参阅《跟老齐学Python:数据分析》一书,书中有详细的说明。
首先,来看一看刚才说的Pandas中有可能爆出的SettingWithCopyWarning
异常。按照下述方式创建DataFrame对象:
>>> data = {"x": 2**np.arange(5),
... "y": 3**np.arange(5),
... "z": np.array([45, 98, 24, 11, 64])}
>>> index = ["a", "b", "c", "d", "e"]
>>> df = pd.DataFrame(data=data, index=index)
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
DataFrame对象就好像一个二维表格一样,如下图所示,最上面一行中的x、y、z是列标签(Column labels),左侧的a/b/c/d/e是行标签(Row labels),中间的就是数据了。
通过DataFrame对象的索引,可以很容易地实现各种操作,比如筛选出z列中所有小于50的记录,可以这样操作:
>>> mask = df["z"] < 50
>>> mask
a True
b False
c True
d True
e False
Name: z, dtype: bool
>>> df[mask]
x y z
a 1 1 45
c 4 9 24
d 8 27 11
mask
是一个有布尔值构成的Series对象,然后用它作为df
的索引,就得到了按照筛选条件返回的记录。本来返回的也是一个DataFrame对象,即df[mast]
,但是,如果你要对这个对象进行操作,比如试图将所有的z
列的值修改为0,按照一般的理解就应该是df[mask]["z"]=0
,如果这样做了,就会爆出异常。
>>> df[mask]["z"] = 0
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
代码的执行结果显示,操作失败,没有能够将筛选出来的记录中的z
列数值修改为0。这是为什么?
还是用图示的方式展现一下上面的操作——虽然失败了,目的是与后面的操作进行对比:
其实,一般情况下,你不用这么做,只需要按照下面的方式做就能够达到目的了:
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc[mask, "z"] = 0
>>> df
x y z
a 1 1 0
b 2 3 98
c 4 9 0
d 8 27 0
e 16 81 64
还有别的方式,也能实现:
>>> df = pd.DataFrame(data=data, index=index)
>>> df["z"]
a 45
b 98
c 24
d 11
e 64
Name: z, dtype: int64
>>> df["z"][mask] = 0
>>> df
x y z
a 1 1 0
b 2 3 98
c 4 9 0
d 8 27 0
e 16 81 64
是不是感觉有点奇怪了。还是看看上面的操作流程:
这张图和前面的图对比一下,似乎也只是下标的顺序不同罢了。是不是感觉有点复杂?还有呢,继续看:
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc[mask]["z"] = 0
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
前面使用.loc[]
没有问题,这里就又报异常了。
先简单总结一下,为了避免上面的问题,一定要:
df["z"][mask] = 0
,不管是不是会报异常,都要避免,因为把握不好就容易出问题。df.loc[mask, 'z'] = 0
,这样不仅意义明确,而且简单可行。当然,对于上面问题的理解,就涉及到下面要说的视图(浅拷贝)和拷贝(深拷贝)问题了。
理解Numpy和Pandas中的视图和拷贝,是非常有必要的。因为我们有时候需要从内存中的数据中拷贝一份,有时候则需要把数据的一部分连同原数据集同时保存。
创建一个Numpy数组:
>>> arr = np.array([1, 2, 4, 8, 16, 32])
>>> arr
array([ 1, 2, 4, 8, 16, 32])
用arr
再创建一个数组,但是这里采切片和以列表作为索引两种方法创建,比如:
>>> arr[1:4:2]
array([2, 8])
>>> arr[[1, 3]]
array([2, 8]))
如果你还不了解数组的索引,也不用担心,可以参考《跟老齐学Python:数据分析》,书里有非常详细的讲解。
然而,用上面两种方法所得到的数组,还是有差别的。
>>> arr[1:4:2].base
array([ 1, 2, 4, 8, 16, 32])
>>> arr[1:4:2].flags.owndata
False
>>> arr[[1, 3]].base
>>> arr[[1, 3]].flags.owndata
True
上面的操作显示,arr[1:4:2]
实现的是浅拷贝,或者得到的是原数组的视图,而arr[[1, 3]]
则是深拷贝(即常说的拷贝)。这就两种操作的差异。
Numpy中的浅拷贝或者视图,意思是它本身并没有数据,看起来像它的哪些数据,其实是原始数组中的数据,或者说,与原始数据共享内存(也称为共享视图)。如果更直观地,用数组的.view()
可以创建数组的视图:
>>> view_of_arr = arr.view()
>>> view_of_arr
array([ 1, 2, 4, 8, 16, 32])
>>> view_of_arr.base
array([ 1, 2, 4, 8, 16, 32])
>>> view_of_arr.base is arr
True
view_of_arr
引用的数组就是原始数组arr
的一个视图,或者浅拷贝,view_of_arr
的属性.base
就是指原始数组arr
,或者说,view_of_arr
没有自己的数据,它的数据都是arr
的。你可以通过属性.flags
来验证:
>>> view_of_arr.flags.owndata
False
如你所见,view_of_arr.flags.owndata
返回了False
,这意味着它没有自己的数据,它使用的是.base
返回的对象的数据。
上图所说明的就是arr
和view_of_arr
指向了同样的数据对象。
Numpy数组的深拷贝,简称拷贝,就是要单独再创建一个拥有自己数据的数组。相对于原数组,经过深拷贝之后所得到的数组,虽然数据内容来自原数组,但它相对于原数组是独立的。我们可以使用.copy()
方法来演示这种深拷贝:
>>> copy_of_arr = arr.copy()
>>> copy_of_arr
array([ 1, 2, 4, 8, 16, 32])
>>> copy_of_arr.base is None
True
>>> copy_of_arr.flags.owndata
True
如你所见,copy_of_arr
没有.base
属性,如果判断其该熟悉的的布尔值,跟None
一样。而属性.flags.owndata
的返回值是True
。
图中显示,两个数组各有一套数据。
那么,视图和拷贝有什么区别呢?其实,前面的演示你已经看出来了。
数组的属性.nbytes
能返回该数组的字节数,下面就用它比较arr, view_of_arr, copy_of_arr
三个数组。
>>> arr.nbytes
48
>>> view_of_arr.nbytes
48
>>> copy_of_arr.nbytes
48
目前,它们具有相同的字节:48字节。注意,看起来好像每个数组应该是8字节(64比特),其实不然,是48字节。
然而,如果使用sys.getsizeof()
函数,则能够直接得到每个数组所占用的内存空间大小,这就能看出它们的区别了:
>>> from sys import getsizeof
>>> getsizeof(arr)
144
>>> getsizeof(view_of_arr)
96
>>> getsizeof(copy_of_arr)
144
arr
and copy_of_arr
hold 144 bytes each. As you’ve seen previously, 48 bytes out of the 144 total are for the data elements. The remaining 96 bytes are for other attributes. view_of_arr
holds only those 96 bytes because it doesn’t have its own data elements.
arr
和copy_of_arr
占用了144字节,注意,其中的48字节用于保存数据元素,而view_of_arr
只占用了96字节,因为它自己没有数据元素,所以就少了48字节。
此外,原数组修改,对视图和拷贝的数组影响各异,请看下面操作:
>>> arr[1] = 64
>>> arr
array([ 1, 64, 4, 8, 16, 32])
>>> view_of_arr
array([ 1, 64, 4, 8, 16, 32])
>>> copy_of_arr
array([ 1, 2, 4, 8, 16, 32])
视图中的元素修改了,但是拷贝的数组中并未修改。上面代码可以用下图演示:
Pandas中也有视图和拷贝,用DataFrame对象的.copy()
方法,可以分别创建视图和拷贝,区别在于参数的配置,如果deep=False
,则为视图,如果deep=True
则为拷贝,并且这种设置是默认值。
>>> df = pd.DataFrame(data=data, index=index)
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
>>> view_of_df = df.copy(deep=False)
>>> view_of_df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
>>> copy_of_df = df.copy()
>>> copy_of_df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
视图和拷贝,看起来是一样的,如果把它们分别转化为Numpy的数组,就会发现差别了:
>>> view_of_df.to_numpy().base is df.to_numpy().base
True
>>> copy_of_df.to_numpy().base is df.to_numpy().base
False
.to_numpy()
返回一个数组,df
和view_of_df
的.base
属性值相同,它们共享相同的数据。例如:
>>> df["z"] = 0
>>> df
x y z
a 1 1 0
b 2 3 0
c 4 9 0
d 8 27 0
e 16 81 0
>>> view_of_df
x y z
a 1 1 0
b 2 3 0
c 4 9 0
d 8 27 0
e 16 81 0
>>> copy_of_df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
将df
的z
列数组设置为0,view_of_df
也跟着变化,但是copy_of_df
中的元素没有修改。
此外,行标签和列标签也都如此:
>>> view_of_df.index is df.index
True
>>> view_of_df.columns is df.columns
True
>>> copy_of_df.index is df.index
False
>>> copy_of_df.columns is df.columns
False
Numpy中的一维数组的切片方法,与Python中的列表、元组的操作一样。但是,对Numpy数组进行切片,得到的是一个视图:
>>> arr = np.array([1, 2, 4, 8, 16, 32])
>>> a = arr[1:3]
>>> a
array([2, 4])
>>> a.base
array([ 1, 2, 4, 8, 16, 32])
>>> a.base is arr
True
>>> a.flags.owndata
False
>>> b = arr[1:4:2]
>>> b
array([2, 8])
>>> b.base
array([ 1, 2, 4, 8, 16, 32])
>>> b.base is arr
True
>>> b.flags.owndata
False
arr
是原始数组,a
和b
是通过切片操作得到的数组,这两个数组与与arr
共享同样的数据,它们没有自己的独立数据,如下图所示:
当你有一个很大的原始数组,但只需要其中的一小部分时,你可以在切片后调用' .copy() ',并用' del '语句删除指向原始数组的变量。通过这种方式,您保留了副本,并从内存中删除了原始数组。
注意:如果原始数组很大,但是你只需要其中的一小部分时,可以先用切片得到一个小数组,然后它的.copy()
,并用del
删除引用原始数组的变量。通过这种方式,您保留了副本,并从内存中删除了原始数组,可以尽可能节省内存。
切片返回的是视图,但是,索引则不同了。下面演示,使用列表作为索引,得到了原始数组的拷贝。
>>> c = arr[[1, 3]]
>>> c
array([2, 8])
>>> c.base is None
True
>>> c.flags.owndata
True
数组c
中包含了原始数组中索引是1
和3
的两个元素,并且它是原始数组的一个拷贝。
拷贝之后,c
和arr
是两个相互独立的数组。下面的例子中,列表中是布尔值,还是以这个列表为下标,获得True
所对应的索引的值。所返回的值,还是原数组的拷贝。
>>> mask = [False, True, False, True, False, False]
>>> d = arr[mask]
>>> d
array([2, 8])
>>> d.base is None
True
>>> d.flags.owndata
True
以上代码,可以用下图演示:
除了可以用列表作为下标,也可以使用Numpy的数组,但是不能用元组。
# `arr` 是原始数组:
arr = np.array([1, 2, 4, 8, 16, 32])
# 切片获得了`a` 和 `b`视图:
a = arr[1:3]
b = arr[1:4:2]
# 以列表为下标得到了`c` and `d`拷贝:
c = arr[[1, 3]]
d = arr[[False, True, False, True, False, False]]
跟前面对视图和拷贝的说明一样,视图都会受到原始数组变化的影响,拷贝不会:
>>> arr[1] = 64
>>> arr
array([ 1, 64, 4, 8, 16, 32])
>>> a
array([64, 4])
>>> b
array([64, 8])
>>> c
array([2, 8])
>>> d
array([2, 8])
如果用链式操作,比如下面的,注意比较视图和拷贝的不同结果:
>>> arr = np.array([1, 2, 4, 8, 16, 32])
>>> arr[1:4:2][0] = 64
>>> arr
array([ 1, 64, 4, 8, 16, 32])
>>> arr = np.array([1, 2, 4, 8, 16, 32])
>>> arr[[1, 3]][0] = 64
>>> arr
array([ 1, 2, 4, 8, 16, 32])
This example illustrates the difference between copies and views when using chained indexing in NumPy.
arr[1:4:2]
返回了视图,它引用了arr
中的数据元素2
和8
,语句arr[1:4:2][0] = 64
的意思是要将索引为1的元素的值设置为64,这个操作对arr
和视图都会产生作用。
而arr[[1, 3]]
返回了拷贝,其中也包括2
和8
两个元素,但是,它们已经不是arr
中的元素了,而是两个新的。arr[[1, 3]][0] = 64
就不会影响arr
了。
以上以一维数组为例,说明了切片和通过索引(下标)返回的不同类型对象,前者是试图,后者是拷贝。那么,如果是多维数组会如何?与一维的情况一样。
>>> arr = np.array([[ 1, 2, 4, 8],
... [ 16, 32, 64, 128],
... [256, 512, 1024, 2048]])
>>> arr
array([[ 1, 2, 4, 8],
[ 16, 32, 64, 128],
[ 256, 512, 1024, 2048]])
>>> a = arr[:, 1:3] # Take columns 1 and 2
>>> a
array([[ 2, 4],
[ 32, 64],
[ 512, 1024]])
>>> a.base
array([[ 1, 2, 4, 8],
[ 16, 32, 64, 128],
[ 256, 512, 1024, 2048]])
>>> a.base is arr
True
>>> b = arr[:, 1:4:2] # Take columns 1 and 3
>>> b
array([[ 2, 8],
[ 32, 128],
[ 512, 2048]])
>>> b.base
array([[ 1, 2, 4, 8],
[ 16, 32, 64, 128],
[ 256, 512, 1024, 2048]])
>>> b.base is arr
True
>>> c = arr[:, [1, 3]] # Take columns 1 and 3
>>> c
array([[ 2, 8],
[ 32, 128],
[ 512, 2048]])
>>> c.base
array([[ 2, 32, 512],
[ 8, 128, 2048]])
>>> c.base is arr
False
>>> d = arr[:, [False, True, False, True]] # Take columns 1 and 3
>>> d
array([[ 2, 8],
[ 32, 128],
[ 512, 2048]])
>>> d.base
array([[ 2, 32, 512],
[ 8, 128, 2048]])
>>> d.base is arr
False
Pandas中的拷贝和视图与Numpy类似。但是,要注意Pandas中的这样一种操作符:.loc[]
, .iloc[]
, .at[]
, and .iat
还是列举几个示例,从中看看Pandas的拷贝和视图。
>>> df = pd.DataFrame(data=data, index=index)
>>> df["a":"c"]
x y z
a 1 1 45
b 2 3 98
c 4 9 24
>>> df["a":"c"].to_numpy().base
array([[ 1, 2, 4, 8, 16],
[ 1, 3, 9, 27, 81],
[45, 98, 24, 11, 64]])
>>> df["a":"c"].to_numpy().base is df.to_numpy().base
True
>>> df = pd.DataFrame(data=data, index=index)
>>> df[["x", "y"]]
x y
a 1 1
b 2 3
c 4 9
d 8 27
e 16 81
>>> df[["x", "y"]].to_numpy().base
array([[ 1, 2, 4, 8, 16],
[ 1, 3, 9, 27, 81]])
>>> df[["x", "y"]].to_numpy().base is df.to_numpy().base
False
在前面我们已经看到,Pandas有时候会抛出SettingWithCopyWarning
异常。下面我们就看看如何避免这种现象。
还是用前面的例子:
>>> df = pd.DataFrame(data=data, index=index)
>>> mask = df["z"] < 50
>>> mask
a True
b False
c True
d True
e False
Name: z, dtype: bool
>>> df[mask]["z"] = 0
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
之所以报异常,是因为df[mask]
返回的是一个拷贝,更准确地说,赋值操作是针对拷贝对象而言的,对原对象df
没有影响。从异常信息中,可以看到修改提示。但是,你不要认为这样就行:
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc[mask]["z"] = 0
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
因为df.loc[mask]
返回的还是一个拷贝,跟前面的意思一样。
如果这样做,就能成功:
>>> df["z"][mask] = 0
>>> df
x y z
a 1 1 0
b 2 3 98
c 4 9 0
d 8 27 0
e 16 81 64
有的时候Pandas可能不会针对拷贝报错,比如:
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc[["a", "c", "e"]]["z"] = 0 # 这么写也不能实现修改,但不抛出异常
>>> df
x y z
a 1 1 45
b 2 3 98
c 4 9 24
d 8 27 11
e 16 81 64
另外,下面的操作是成立的,也会抛出异常:
>>> df = pd.DataFrame(data=data, index=index)
>>> df[:3]["z"] = 0 # 操作成功,但是有异常抛出
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 0
b 2 3 0
c 4 9 0
d 8 27 11
e 16 81 64
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc["a":"c"]["z"] = 0 # 操作成功,但是有异常抛出
__main__: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
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
>>> df
x y z
a 1 1 0
b 2 3 0
c 4 9 0
d 8 27 11
e 16 81 64
通常,对于上述示例中的操作意图,使用.loc
实现,并且是按照下面的方式:
>>> df = pd.DataFrame(data=data, index=index)
>>> df.loc[mask, "z"] = 0
>>> df
x y z
a 1 1 0
b 2 3 98
c 4 9 0
d 8 27 0
e 16 81 64
避免用链式索引,这种方法最有效了。
严格来说,SettingWithCopyWarning
只是提示或者警告,不是错误,你的代码并不会因为它而中断,如果你看着它不爽,可以修改,利用下面的配置方法:
pd.set_option("mode.chained_assignment", "raise")
抛出 SettingWithCopyException
.pd.set_option("mode.chained_assignment", "warn")
抛出 SettingWithCopyWarning
. 这是默认设置pd.set_option("mode.chained_assignment", None)
包括警告和错误信息。例如,用下面的方式,就可以将SettingWithCopyWarning
替换为SettingWithCopyException
。
>>> df = pd.DataFrame(
... data={("powers", "x"): 2**np.arange(5),
... ("powers", "y"): 3**np.arange(5),
... ("random", "z"): np.array([45, 98, 24, 11, 64], dtype=float)},
... index=["a", "b", "c", "d", "e"]
... )
>>> pd.set_option("mode.chained_assignment", "raise")
>>> df["powers"]["x"] = 0
另外,你可以使用get_option()
函数查看当前的警告等级。
>>> pd.get_option("mode.chained_assignment")
'raise'
本文讨论了Numpy和Pandas中的视图和拷贝,并且了解了SettingWithCopyWarning
异常的有关问题。
参考文献:https://realpython.com/pandas-settingwithcopywarning/