7.7 处理缺失数据
原文:Handling Missing Data 译者:飞龙 协议:CC BY-NC-SA 4.0 本节是《Python 数据科学手册》(Python Data Science Handbook)的摘录。
许多教程中的数据与现实世界中的数据之间的差异在于,真实世界的数据很少是干净和同构的。特别是,许多有趣的数据集缺少一些数据。为了使事情变得更复杂,不同的数据源可能以不同的方式标记缺失数据。
在本节中,我们将讨论缺失数据的一些一般注意事项,讨论 Pandas 如何选择来表示它,并演示一些处理 Python 中的缺失数据的 Pandas 内置工具。在整本书中,我们将缺失数据称为空值或NaN
值。
缺失数据惯例中的权衡
许多方案已经开发出来,来指示表格或DataFrame
中是否存在缺失数据。通常,它们围绕两种策略中的一种:使用在全局表示缺失值的掩码,或选择表示缺失条目的标记值。
在掩码方法中,掩码可以是完全独立的布尔数组,或者它可以在数据表示中占用一个比特,在本地表示值的空状态。
在标记方法中,标记值可能是某些特定于数据的惯例,例如例如使用-9999
或某些少见的位组合来表示缺失整数值,或者它可能是更全局的惯例,例如使用NaN
(非数字)表示缺失浮点值,这是一个特殊值,它是 IEEE 浮点规范的一部分。
这些方法都没有权衡:使用单独的掩码数组需要分配额外的布尔数组,这会增加存储和计算的开销。标记值减少了可以表示的有效值的范围,并且可能需要 CPU 和 GPU 算法中的额外(通常是非最优的)逻辑。 像NaN
这样的常见特殊值不适用于所有数据类型。
在大多数情况下,不存在普遍最佳选择,不同的语言和系统使用不同的惯例。例如,R 语言使用每种数据类型中的保留位组合,作为表示缺失数据的标记值,而 SciDB 系统使用表示 NA 状态的额外字节,附加到每个单元。
Pandas 中的缺失数据
Pandas 处理缺失值的方式受到其对 NumPy 包的依赖性的限制,NumPy 包没有非浮点数据类型的 NA 值的内置概念。
Pandas 可以遵循 R 的指导,为每个单独的数据类型指定位组合来表示缺失值,但这种方法结果相当笨拙。虽然 R 包含四种基本数据类型,但 NumPy 支持更多:例如,R 具有单个整数类型,但是一旦考虑到编码的可用精度,签名和字节顺序,NumPy 支持十四个基本整数类型。
在所有可用的 NumPy 类型中保留特定的位组合,将产生各种类型的各种操作的大量开销,甚至可能需要 NumPy 包的新分支。 此外,对于较小的数据类型(例如 8 位整数),牺牲一个位用作掩码,将显着减小它可以表示的值的范围。
NumPy 确实支持掩码数组吗?也就是说,附加了一个独立的布尔掩码数组的数组,用于将数据标记为“好”或“坏”。Pandas 可能源于此,但是存储,计算和代码维护的开销,使得这个选择变得没有吸引力。
考虑到这些约束,Pandas 选择使用标记来丢失数据,并进一步选择使用两个已经存在的 Python 空值:特殊浮点值NaN
和 Python None
对象。我们将要看到,这种选择有一些副作用,但实际上在大多数相关情况下,最终都是很好的妥协。
None
:Python 风格的缺失数据
Pandas 使用的第一个标记值是None
,这是一个 Python 单例对象,通常用于 Python 代码中的缺失数据。因为它是一个 Python 对象,所以None
不能用于任何 NumPy/Pandas 数组,只能用于数据类型为'object'
的数组(即 Python 对象数组):
import numpy as np
import pandas as pd
vals1 = np.array([1, None, 3, 4])
vals1
# array([1, None, 3, 4], dtype=object)
这个dtype = object
意味着,它是最好的公共类型表示。NumPy 可以推断出,数组的内容是 Python 对象。虽然这种对象数组对于某些目的很有用,但是对数据的任何操作都将在 Python 层面完成,与具有原生类型的数组的常见快速操作相比,其开销要大得多:
for dtype in ['object', 'int']:
print("dtype =", dtype)
%timeit np.arange(1E6, dtype=dtype).sum()
print()
'''
dtype = object
10 loops, best of 3: 78.2 ms per loop
dtype = int
100 loops, best of 3: 3.06 ms per loop
'''
在数组中使用 Python 对象也意味着,如果你在一个带有None
值的数组中执行sum()
或min()
之类的聚合,你通常会得到错误:
vals1.sum()
'''
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-4-749fd8ae6030> in <module>()
----> 1 vals1.sum()
/Users/jakevdp/anaconda/lib/python3.5/site-packages/numpy/core/_methods.py in _sum(a, axis, dtype, out, keepdims)
30
31 def _sum(a, axis=None, dtype=None, out=None, keepdims=False):
---> 32 return umr_sum(a, axis, dtype, out, keepdims)
33
34 def _prod(a, axis=None, dtype=None, out=None, keepdims=False):
TypeError: unsupported operand type(s) for : 'int' and 'NoneType'
'''
这反映了一个事实,即整数和None
之间的加法是未定义的。
NaN
:缺失的数值数据
另一个缺失的数据表示,NaN
(“非数字”的首字母缩写)是不同的;它是所有系统都识别的特殊浮点值,使用标准 IEEE 浮点表示:
vals2 = np.array([1, np.nan, 3, 4])
vals2.dtype
# dtype('float64')
请注意,NumPy 为此数组选择了一个原生浮点类型:这意味着与之前的对象数组不同,此数组支持推送到编译代码中的快速操作。你应该知道NaN
有点像数据病毒 - 它会感染它触及的任何其他对象。无论操作如何,NaN
的算术结果都是另一个NaN
:
1 np.nan
# nan
0 * np.nan
# nan
请注意,这意味着值的聚合是定义良好的(即,它们不会导致错误),但并不总是有用:
代码语言:javascript复制vals2.sum(), vals2.min(), vals2.max()
# (nan, nan, nan)
NumPy 确实提供了一些忽略这些缺失值的特殊聚合:
代码语言:javascript复制np.nansum(vals2), np.nanmin(vals2), np.nanmax(vals2)
# (8.0, 1.0, 4.0)
请记住,NaN
是一个特殊浮点值;整数,字符串或其他类型没有等效的NaN
值。
Pandas 中的NaN
和None
NaN
和None
都有它们的位置,并且 Pandas 的构建是为了几乎可以互换地处理这两个值,在适当的时候在它们之间进行转换:
pd.Series([1, np.nan, 2, None])
'''
0 1.0
1 NaN
2 2.0
3 NaN
dtype: float64
'''
对于没有可用标记值的类型,当存在 NA 值时,Pandas 会自动进行类型转换。例如,如果我们将整数数组中的值设置为np.nan
,它将自动向上转换为浮点类型来兼容 NA:
x = pd.Series(range(2), dtype=int)
x
'''
0 0
1 1
dtype: int64
'''
x[0] = None
x
'''
0 NaN
1 1.0
dtype: float64
'''
请注意,除了将整数数组转换为浮点数外,Pandas 还会自动将None
转换为NaN
值。(请注意,有人建议未来向 Pandas 添加原生整数 NA;截至本文撰写时,尚未包含此内容。)
虽然与 R 等领域特定语言中,更为统一的 NA 值方法相比,这种黑魔法可能会有些笨拙,但 Pandas 标记值方法在实践中运作良好,根据我的经验,很少会产生问题。
下表列出了引入 NA 值时 Pandas 中的向上转换惯例:
类型 | 储存 NA 时的惯例 | NA 标记值 |
---|---|---|
floating | 不变 | np.nan |
object | 不变 | None或np.nan |
integer | 转换为float64 | np.nan |
boolean | 转换为object | None或np.nan |
请记住,在 Pandas 中,字符串数据始终与object dtype
一起存储。
空值上的操作
正如我们所看到的,Pandas 将None
和NaN
视为基本可互换的,用于指示缺失值或空值。为了促进这个惯例,有几种有用的方法可用于检测,删除和替换 Pandas 数据结构中的空值。他们是:
isnull()
: 生成表示缺失值的布尔掩码notnull()
:isnull()
的反转dropna()
: 返回数据的过滤后版本fillna()
: 返回数据的副本,填充了缺失值
我们将结束本节,简要探讨和演示这些例程。
检测控制
Pandas 数据结构有两种有用的方法来检测空数据:isnull()
和notnull()
。任何一个都返回数据上的布尔掩码。例如:
data = pd.Series([1, np.nan, 'hello', None])
data.isnull()
'''
0 False
1 True
2 False
3 True
dtype: bool
'''
如“数据索引和选择”中所述,布尔掩码可以直接用作Series
或DataFrame
的索引:
data[data.notnull()]
'''
0 1
2 hello
dtype: object
'''
isnull()
和notnull()
方法为DataFrame
生成类似的布尔结果。
删除空值
除了之前使用的掩码之外,还有一些方便的方法,dropna()
(删除 NA 值)和fillna()
(填充 NA 值)。 对于Series
,结果很简单:
data.dropna()
'''
0 1
2 hello
dtype: object
'''
对于DataFrame
,还有更多选项。考虑以下DataFrame
:
df = pd.DataFrame([[1, np.nan, 2],
[2, 3, 5],
[np.nan, 4, 6]])
df
0 | 1 | 2 | |
---|---|---|---|
0 | 1.0 | NaN | 2 |
1 | 2.0 | 3.0 | 5 |
2 | NaN | 4.0 | 6 |
我们不能从DataFrame
中删除单个值;我们只能删除完整行或完整列。取决于应用,你可能需要其中一个,因此dropna()
为DataFrame
提供了许多选项。
默认情况下,dropna()
将删除包含空值的所有行:
df.dropna()
0 | 1 | 2 | |
---|---|---|---|
1 | 2.0 | 3.0 | 5 |
或者,你可以沿不同的轴删除 NA 值; axis = 1
删除包含空值的所有列:
df.dropna(axis='columns')
2 | |
---|---|
0 | 2 |
1 | 5 |
2 | 6 |
但这也会丢掉一些好的数据; 你可能更愿意删除全部为 NA 值或大多数为 NA 值的行或列。这可以通过how
或thresh
参数来指定,这些参数能够精确控制允许通过的空值数量。
默认值是how ='any'
,这样任何包含空值的行或列(取决于axis
关键字)都将被删除。你也可以指定how ='all'
,它只会丢弃全部为空值的行/列:
df[3] = np.nan
df
0 | 1 | 2 | 3 | |
---|---|---|---|---|
0 | 1.0 | NaN | 2 | NaN |
1 | 2.0 | 3.0 | 5 | NaN |
2 | NaN | 4.0 | 6 | NaN |
df.dropna(axis='columns', how='all')
0 | 1 | 2 | |
---|---|---|---|
0 | 1.0 | NaN | 2 |
1 | 2.0 | 3.0 | 5 |
2 | NaN | 4.0 | 6 |
对于更细粒度的控制,thresh
参数允许你为要保留的行/列指定最小数量的非空值:
df.dropna(axis='rows', thresh=3)
0 | 1 | 2 | 3 | |
---|---|---|---|---|
1 | 2.0 | 3.0 | 5 | NaN |
这里删除了第一行和最后一行,因为它们只包含两个非空值。
填充空值
有时比起删除 NA 值,你宁愿用有效值替换它们。这个值可能是单个数字,如零,或者可能是某种良好的替换或插值。你可以将isnull()
方法用作掩码,原地执行此操作,但因为它是如此常见的操作,Pandas 提供fillna()
方法,该方法返回数组的副本,其中空值已替换。
考虑下面的Series
:
data = pd.Series([1, np.nan, 2, None, 3], index=list('abcde'))
data
'''
a 1.0
b NaN
c 2.0
d NaN
e 3.0
dtype: float64
'''
我们可以使用单个值填充 NA 条目,例如零:
代码语言:javascript复制data.fillna(0)
'''
a 1.0
b 0.0
c 2.0
d 0.0
e 3.0
dtype: float64
'''
我们可以指定前向填充来传播前一个值:
代码语言:javascript复制# 向前填充
data.fillna(method='ffill')
'''
a 1.0
b 1.0
c 2.0
d 2.0
e 3.0
dtype: float64
'''
或者我们可以指定反向填充,来向后传播下一个值:
代码语言:javascript复制# 向后填充
data.fillna(method='bfill')
'''
a 1.0
b 2.0
c 2.0
d 3.0
e 3.0
dtype: float64
'''
对于DataFrame
,选项也类似,但我们也可以指定axis
,沿着该轴进行填充:
df
0 | 1 | 2 | 3 | |
---|---|---|---|---|
0 | 1.0 | NaN | 2 | NaN |
1 | 2.0 | 3.0 | 5 | NaN |
2 | NaN | 4.0 | 6 | NaN |
df.fillna(method='ffill', axis=1)
0 | 1 | 2 | 3 | |
---|---|---|---|---|
0 | 1.0 | 1.0 | 2.0 | 2.0 |
1 | 2.0 | 3.0 | 5.0 | 5.0 |
2 | NaN | 4.0 | 6.0 | 6.0 |
请注意,如果在前向填充期间前一个值不可用,则 NA 值仍然存在。