Pandas 处理缺失数据
文章目录
- Pandas 处理缺失数据
- 缺失数据约定的权衡
- Pandas 中的缺失数据
- None 作为哨兵值
- NaN:缺失的数值数据
- Pandas 中的 NaN 和 None
- Pandas 可空数据类型
- 对缺失值的操作
- 检测空值
- 删除空值
- 填充空值
- 总结
Pandas 处理缺失数据
许多教程中的数据与现实世界中的数据有很大不同,现实世界中的数据很少是干净且同质的。
尤其是,许多有趣的数据集都会存在一定程度的数据缺失。
更复杂的是,不同的数据来源可能会用不同的方式表示缺失数据。
我们将讨论处理缺失数据的一些常规注意事项,了解 Pandas 如何表示缺失数据,并探索 Pandas 在 Python 中处理缺失数据的一些内置工具。
在本书中及整个过程中,我会将缺失数据统称为 null、NaN 或 NA 值。
缺失数据约定的权衡
为了在表格或 DataFrame
中跟踪缺失数据的存在,已经开发了多种方法。通常,这些方法围绕两种策略展开:使用全局指示缺失值的 掩码,或选择一个表示缺失项的 哨兵值。
在掩码方法中,掩码可以是一个完全独立的布尔数组,也可以通过占用数据表示中的某一位来局部指示值的缺失状态。
在哨兵值方法中,哨兵值可以是某种特定于数据的约定,比如用 –9999 或某个罕见的位模式表示缺失的整数值,或者采用更通用的约定,比如用 NaN
(非数字 Not a Number)表示缺失的浮点值,这是 IEEE 浮点规范中的特殊值。
这两种方法都存在权衡。使用单独的掩码数组需要分配额外的布尔数组,这会增加存储和计算的开销。哨兵值则减少了可表示的有效值范围,并且可能需要额外(通常是非优化的)CPU 和 GPU 算术逻辑,因为像 NaN
这样的常用特殊值并不适用于所有数据类型。
正如大多数没有普遍最优选择的情况一样,不同的语言和系统采用了不同的约定。例如,R 语言在每种数据类型中使用保留的位模式作为指示缺失数据的哨兵值,而 SciDB 系统则为每个单元格附加一个额外的字节来指示 NA 状态。
Pandas 中的缺失数据
Pandas 处理缺失值的方式受到其对 NumPy 包的依赖的限制,而 NumPy 对于非浮点数据类型并没有内置的 NA(缺失值)概念。
或许 Pandas 可以像 R 一样,为每种数据类型指定位模式来表示空值,但这种方法实际上非常繁琐。R 只有 4 种主要数据类型,而 NumPy 支持的类型远远多于此:例如,R 只有一种整数类型,而 NumPy 在考虑不同的位宽、符号和字节序后,基本整数类型就有 14 种。如果要在所有可用的 NumPy 类型中保留特定的位模式,将导致在各种类型的操作中需要大量特殊处理,甚至可能需要为 NumPy 包开发新的分支。此外,对于较小的数据类型(如 8 位整数),牺牲一位作为掩码会显著减少其可表示的数值范围。
由于这些限制和权衡,Pandas 在存储和处理空值时有两种“模式”:
- 默认模式是使用哨兵值(sentinel)来表示缺失数据,根据数据类型使用
NaN
或None
作为哨兵值。 - 另外,你可以选择使用 Pandas 提供的可空数据类型(nullable dtypes,后文会详细介绍),这会创建一个额外的掩码数组来跟踪缺失项,并将这些缺失项以特殊的
pd.NA
值呈现给用户。
无论采用哪种方式,Pandas API 提供的数据操作和处理方法都会以可预测的方式处理和传播这些缺失项。但为了更好地理解这些选择背后的原因,我们需要快速了解一下 None
、NaN
和 NA
的权衡。像往常一样,我们先导入 NumPy 和 Pandas:
import numpy as np
import pandas as pd
None 作为哨兵值
对于某些数据类型,Pandas 使用 None
作为哨兵值。None
是一个 Python 对象,这意味着任何包含 None
的数组都必须具有 dtype=object
,即它必须是一个 Python 对象的序列。
例如,观察如果你将 None
传递给 NumPy 数组会发生什么:
vals1 = np.array([1, None, 2, 3])
vals1
array([1, None, 2, 3], dtype=object)
这种 dtype=object
表示 NumPy 能为数组内容推断出的最佳通用类型是 Python 对象。
以这种方式使用 None
的缺点是,对数据的操作会在 Python 层面进行,开销远大于对原生类型数组的高效操作:
%timeit np.arange(1E6, dtype=int).sum()
2.89 ms ± 24.6 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit np.arange(1E6, dtype=object).sum()
26.7 ms ± 454 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
进一步,由于 Python 不支持使用 None
进行算术运算,因此诸如 sum
或 min
之类的聚合操作通常会导致错误:
vals1.sum()
---------------------------------------------------------------------------TypeError Traceback (most recent call last)Cell In[5], line 1
----> 1 vals1.sum()File d:\Source\Repos\Visual Studio Code\MachineLearning-notes\.venv\Lib\site-packages\numpy\_core\_methods.py:53, in _sum(a, axis, dtype, out, keepdims, initial, where)51 def _sum(a, axis=None, dtype=None, out=None, keepdims=False,52 initial=_NoValue, where=True):
---> 53 return umr_sum(a, axis, dtype, out, keepdims, initial, where)TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
鉴于此,Pandas 不会在其数值数组中使用 None
作为哨兵值。
NaN:缺失的数值数据
另一种缺失数据的哨兵值是 NaN
,它有所不同;NaN
是一种特殊的浮点值,被所有采用标准 IEEE 浮点表示法的系统所识别:
vals2 = np.array([1, np.nan, 3, 4])
vals2
array([ 1., nan, 3., 4.])
请注意,NumPy 为该数组选择了本地浮点类型:这意味着与之前的 object 数组不同,这个数组支持被编译代码加速的快速操作。
需要记住的是,NaN
有点像数据病毒——它会“感染”它接触到的任何其他对象。
无论进行何种运算,与 NaN 进行算术运算的结果仍然是另一个 NaN
:
1 + np.nan
nan
0 * np.nan
nan
这意味着对这些值进行聚合操作是有定义的(即不会导致错误),但结果并不总是有用:
vals2.sum(), vals2.min(), vals2.max()
(np.float64(nan), np.float64(nan), np.float64(nan))
也就是说,NumPy 提供了能够识别 NaN
并忽略这些缺失值的聚合函数版本:
np.nansum(vals2), np.nanmin(vals2), np.nanmax(vals2)
(np.float64(8.0), np.float64(1.0), np.float64(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
值。
虽然与 R 这类领域专用语言中对 NA 值更统一的处理方式相比,这种哨兵值/类型转换的“魔法”看起来有些取巧,但实际上 Pandas 的这种做法在实践中效果很好,而且据我的经验,只有极少数情况下会引发问题。
下表列出了在 Pandas 中引入 NA 值时的类型提升约定:
类型类别 | 存储 NA 时的转换方式 | NA 哨兵值 |
---|---|---|
floating | 无变化 | np.nan |
object | 无变化 | None 或 np.nan |
integer | 转换为 float64 | np.nan |
boolean | 转换为 object | None 或 np.nan |
请记住,在 Pandas 中,字符串数据始终以 object
类型存储。
Pandas 可空数据类型
在早期版本的 Pandas 中,NaN
和 None
作为哨兵值是唯一可用的缺失数据表示方法。这带来的主要问题在于隐式类型转换:例如,无法表示带有缺失数据的真正整数数组。
为了解决这个问题,Pandas 后来引入了可空数据类型(nullable dtypes),它们与常规数据类型的区别在于名称的大小写(例如,pd.Int32
与 np.int32
)。为了向后兼容,只有在明确指定时才会使用这些可空数据类型。
例如,下面是一个包含缺失数据的整数序列 Series
,它由一个包含三种可用缺失值标记的列表创建:
pd.Series([1, np.nan, 2, None, pd.NA], dtype='Int32')
0 1
1 <NA>
2 2
3 <NA>
4 <NA>
dtype: Int32
这种表示方法在本文后续讨论的所有操作中都可以与其他表示方法互换使用。
对缺失值的操作
如前所述,Pandas 将 None
、NaN
和 NA
视为本质上可以互换的缺失值标记。
为了方便这一约定,Pandas 提供了多种方法来检测、移除和替换 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
或 axis='columns'
可以删除所有包含空值的列:
df.dropna(axis='columns')
2 | |
---|---|
0 | 2 |
1 | 5 |
2 | 6 |
但这样也会丢弃一些有用的数据;你可能更希望只删除那些全部为 NA 的行或列,或者 NA 占多数的行或列。
这可以通过 how
或 thresh
参数来指定,从而精细控制允许通过的空值数量。
默认情况下,how='any'
,即只要某行或某列包含空值就会被删除。
你也可以指定 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 |
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'), dtype='Int32')
data
a 1
b <NA>
c 2
d <NA>
e 3
dtype: Int32
我们可以用单个值(例如零)来填充 NA 项:
data.fillna(0)
a 1
b 0
c 2
d 0
e 3
dtype: Int32
我们可以指定前向填充(forward fill),将前一个有效值向前传播:
# 前向填充,旧方法,在后续Pandas版本中可能会被弃用
data.fillna(method='ffill')
\AppData\Local\Temp\ipykernel_13548\3988156040.py:2: FutureWarning: Series.fillna with 'method' is deprecated and will raise in a future version. Use obj.ffill() or obj.bfill() instead.data.fillna(method='ffill')a 1
b 1
c 2
d 2
e 3
dtype: Int32
# 前向填充
data.ffill()
a 1
b 1
c 2
d 2
e 3
dtype: Int32
或者我们可以指定反向填充(backward fill),将下一个有效值向后传播:
# 后向填充,旧方法,在后续Pandas版本中可能会被弃用
data.fillna(method='bfill')
\AppData\Local\Temp\ipykernel_13548\1439583404.py:2: FutureWarning: Series.fillna with 'method' is deprecated and will raise in a future version. Use obj.ffill() or obj.bfill() instead.data.fillna(method='bfill')a 1
b 2
c 2
d 3
e 3
dtype: Int32
# 后向填充
data.bfill()
a 1
b 2
c 2
d 3
e 3
dtype: Int32
在 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.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 值将保持不变。
总结
本文介绍了现实世界中缺失数据的常见情况及其在 Pandas 中的处理方式。我们讨论了缺失值的两种主要表示方法(掩码和哨兵值),并重点介绍了 Pandas 对 None
、NaN
和 pd.NA
的支持及其背后的权衡。通过示例演示了缺失值的检测(isnull
、notnull
)、删除(dropna
)和填充(fillna
、ffill
、bfill
)等常用操作。此外,还介绍了 Pandas 的可空数据类型(如 Int32
),使得带缺失值的整数数据能够被更好地支持。掌握这些方法有助于在数据分析过程中更高效、灵活地处理缺失数据问题。