NEP 12 — NumPy 中的缺失数据功能#

作者:

Mark Wiebe <mwwiebe@gmail.com>

版权:

Copyright 2011 by Enthought, Inc

许可证:

CC By-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/)

日期:

2011-06-23

状态:

Deferred

目录#

摘要#

有兴趣在 NumPy 中处理缺失数据的用户通常会被指向 ndarray 的掩码数组子类,即“numpy.ma”。此类有许多用户强烈依赖其功能,但习惯于 R 项目中缺失数据占位符“NA”的深度集成,或者觉得其编程接口具有挑战性或不一致的人倾向于不使用它。

本 NEP 提议将基于掩码的缺失数据解决方案集成到 NumPy 中,并提供一个基于位模式的附加缺失数据解决方案,该方案可以同时实现或稍后与基于掩码的解决方案无缝集成。

本提案中的基于掩码和基于位模式的解决方案提供完全相同的缺失值抽象,在性能、内存开销和灵活性方面存在一些差异。

基于掩码的解决方案更灵活,支持基于位模式解决方案的所有行为,但在元素被掩码时,会将隐藏的值保持不变。

基于位模式的解决方案需要的内存更少,与 R 中使用的 64 位浮点表示兼容,但它不保留隐藏的值,实际上需要从底层 dtype 中窃取至少一个位模式来表示缺失值 NA。

这两种解决方案都是通用的,因为它们可以非常轻松地用于自定义数据类型,掩码解决方案无需任何努力,而位模式解决方案需要选择一个要牺牲的位模式。

缺失数据的定义#

为了能够开发一种关于各种 NumPy 函数将执行何种计算的直观理解,必须应用一个关于缺失元素含义的一致概念模型。弄清楚人们在处理“缺失数据”时需要或想要的行为似乎很棘手,但我认为这可以归结为两种不同的、在内部自洽的想法。

其中,“未知但存在的数据”解释可以严格应用于所有计算,而另一种解释对于一些统计运算(如标准差)是有意义的,但对于矩阵乘积等线性代数运算则没有意义。因此,将“未知但存在的数据”作为默认解释是更优的,它提供了跨所有计算的一致模型,并且对于其他解释有意义的操作,可以添加一个可选参数“skipna=”。

对于那些希望将另一种解释作为默认解释的人来说, elsewhere 提出的用于使用 _numpy_ufunc_ 成员函数自定义子类 ufunc 行为的机制,将允许创建一个具有不同默认值的子类。

未知但存在的数据(NA)#

这是 R 项目所采取的方法,将缺失元素定义为具有有效值但未知,或为 NA(不可用)的东西。本提案将此行为采纳为所有涉及缺失值的操作的默认行为。

在此解释下,几乎任何带有缺失输入的计算都会产生一个缺失的输出。例如,如果“a”只包含一个缺失元素,则“sum(a)”将产生一个缺失值。当输出值不依赖于输入之一时,输出一个非 NA 的值是合理的,例如 logical_and(NA, False) == False。

一些更复杂的算术运算,例如矩阵乘积,在此解释下是明确定义的,并且结果应与缺失值为 NaN 的情况相同。实际实现这些情况到理论极限可能不值得,并且在许多情况下,抛出异常或返回所有缺失值可能比进行精确计算更受青睐。

不存在或被跳过的数据(IGNORE)#

另一种有用的解释是,缺失元素应被视为数组中不存在,并且操作应尽其所能根据剩余数据解释其含义。在这种情况下,“mean(a)”将仅计算可用值的平均值,并根据哪些值缺失来调整其使用的总和和计数。为了保持一致性,全为缺失值的数组的平均值必须产生与不含缺失值支持的零大小数组的平均值相同的结果。

这种数据可能在将稀疏采样数据符合为规则采样模式时出现,并且是尝试为许多统计查询获得最佳猜测答案时使用的一种有用解释。

在 R 中,许多函数都接受一个名为“na.rm=T”的参数,这意味着将数据视为 NA 值不是数据集的一部分。本提案为此目的定义了一个标准参数“skipna=True”。

缺失值的实现技术#

除了缺失值存在两种不同的解释外,还有两种不同的常用缺失值实现技术。虽然现有技术实现之间存在一些不同的默认行为,但我认为新实现的设计选择必须基于其优点,而不是死记硬背以前的设计。

掩码和位模式都有不同的强项和弱项,具体取决于应用上下文。因此,本 NEP 提议同时实现这两种技术。为了能够编写通用的“缺失值”代码,而无需担心其使用的数组采用了哪种方法,对于这两种实现,缺失值的语义将是相同的。

标记缺失值的位模式(bitpattern)#

选择一个或多个位模式,例如具有特定有效负载的 NaN,来表示缺失值占位符 NA。

这种方法的一个后果是,将 NA 赋值会改变保存值的位,因此该值将丢失。

此外,对于整数等类型,必须牺牲一个合理且合适的有效值来启用此功能。

标记缺失值的布尔掩码(mask)#

掩码是一个并行布尔数组,每个元素要么是一个字节,要么是一个位,与现有数组数据一起分配。在本 NEP 中,采用的约定是 True 表示元素有效(未掩码),False 表示元素为 NA。

通过谨慎编写任何处理值和掩码的 C 算法,可以使被掩码的值的内存永远不被写入。此功能允许多个对相同数据的不同视图,其中包含不同的缺失值选择,这是邮件列表中许多人请求的功能。

这种方法对底层数据类型的取值没有任何限制,它可以是任何二进制模式,而不会影响 NA 的行为。

术语表#

由于上面关于不同概念及其关系的讨论很难理解,因此这里提供了本 NEP 中使用的术语的更简洁定义。

NA(Not Available/Propagate,不可用/传播)

计算无法识别的值的占位符。该值可能被掩码暂时隐藏,可能由于硬盘损坏而丢失,或由于任何原因而丢失。对于求和和乘积,这意味着如果任何输入是 NA,则产生 NA。这与 R 项目中的 NA 相同。

IGNORE(忽略/跳过)

计算应视为没有值存在或不可能存在于此处的占位符。对于求和,这意味着像值为零一样处理;对于乘积,这意味着像值为一一样处理。就像数组以某种方式压缩而不包含该元素一样。

bitpattern

一种实现 NA 或 IGNORE 的技术,其中从值数据类型的所有可能位模式中选择特定的位模式来表示元素是 NA 或 IGNORE。

mask

一种实现 NA 或 IGNORE 的技术,其中使用与数据数组并行的一个布尔或枚举数组来指示哪些元素是 NA 或 IGNORE。

numpy.ma

NumPy 代码库中现有的特定形式的掩码数组的实现。

Python API

暴露给 Python 代码使用的缺失值的所有接口机制。此 API 设计为 Pythonic 风格,并尽可能融入 NumPy 的工作方式。

C API

为使用 C 编写的 CPython 扩展程序暴露的所有实现机制,这些扩展程序希望支持 NumPy 缺失值支持。此 API 设计为在 C 中尽可能自然,并且通常优先考虑灵活性和高性能。

Python 中看到的缺失值#

处理缺失值#

NumPy 将获得一个名为 numpy.NA 的全局单例,类似于 None,但其语义反映了其作为缺失值的状态。特别是,尝试将其视为布尔值将引发异常,而与它进行比较将产生 numpy.NA 而不是 True 或 False。这些基础知识借鉴了 R 项目中 NA 值的行为。要深入了解这些想法,https://en.wikipedia.org/wiki/Ternary_logic#Kleene_logic 提供了一个起点。

例如,

>>> np.array([1.0, 2.0, np.NA, 7.0], maskna=True)
array([1., 2., NA, 7.], maskna=True)
>>> np.array([1.0, 2.0, np.NA, 7.0], dtype='NA')
array([1., 2., NA, 7.], dtype='NA[<f8]')
>>> np.array([1.0, 2.0, np.NA, 7.0], dtype='NA[f4]')
array([1., 2., NA, 7.], dtype='NA[<f4]')

分别产生值为 [1.0, 2.0, <不可访问>, 7.0] / 掩码 [暴露, 暴露, 隐藏, 暴露],以及掩码和 NA dtype 版本的值 [1.0, 2.0, <NA 位模式>, 7.0] 的数组。

np.NA 单例可以接受 dtype= 关键字参数,表示它应被视为特定数据类型的 NA。这是一种以 NumPy 标量方式保留 dtype 的机制。以下是它的样子

>>> np.sum(np.array([1.0, 2.0, np.NA, 7.0], maskna=True))
NA(dtype='<f8')
>>> np.sum(np.array([1.0, 2.0, np.NA, 7.0], dtype='NA[f8]'))
NA(dtype='NA[<f8]')

将值赋给数组始终会使该元素不再是 NA,并会透明地取消掩码(如果需要)。将 numpy.NA 赋给数组会掩盖该元素或为特定 dtype 分配 NA 位模式。在基于掩码的实现中,除了通过赋值来取消掩码外,缺失值后面的存储可能永远不会以任何方式被访问。

要测试一个值是否缺失,将提供函数“np.isna(arr[0])”。NumPy 标量的一个关键原因是允许它们的值进入字典。

所有写入掩码数组的操作都不会影响该值,除非它们也取消了该值的掩码。这使得隐藏元素后面的存储仍然可以被依赖,如果它们仍然可以从另一个不掩盖它们的视图访问的话。例如,以下是在 missingdata 工作进行中的分支上运行的

>>> a = np.array([1,2])
>>> b = a.view(maskna=True)
>>> b
array([1, 2], maskna=True)
>>> b[0] = np.NA
>>> b
array([NA, 2], maskna=True)
>>> a
array([1, 2])
>>> # The underlying number 1 value in 'a[0]' was untouched

在基于掩码的实现和基于位模式的实现之间复制值将透明地正确处理,将位模式转换为掩码值,或将掩码值转换为适当的位模式。唯一的例外是,如果掩码数组中的一个有效值碰巧具有 NA 位模式,则将此值复制到该 dtype 的 NA 形式也会使其变为 NA。

当 NA dtypes 和掩码数组之间的操作进行时,结果将是掩码数组。这是因为在某些情况下,NA dtypes 无法表示掩码数组中的所有值,因此转换为掩码数组是保留数据所有方面的唯一方法。

如果 np.NA 或掩码值被复制到未启用缺失值支持的数组中,将引发异常。向目标数组添加掩码会很麻烦,因为拥有掩码将是一种“病毒式”属性,会消耗额外的内存并以意想不到的方式降低性能。

默认情况下,字符串“NA”将用于在 str 和 repr 输出中表示缺失值。全局配置将允许更改此设置,从而扩展 nan 和 inf 的处理方式。以下内容在当前草稿实现中有效

>>> a = np.arange(6, maskna=True)
>>> a[3] = np.NA
>>> a
array([0, 1, 2, NA, 4, 5], maskna=True)
>>> np.set_printoptions(nastr='blah')
>>> a
array([0, 1, 2, blah, 4, 5], maskna=True)

对于浮点数,Inf 和 NaN 是与缺失值分开的概念。如果在具有默认缺失值支持的数组中发生除零错误,将产生未掩码的 Inf 或 NaN。要掩盖这些值,可以使用进一步的“a[np.logical_not(a.isfinite(a))] = np.NA”来实现。对于位模式方法,后面部分所述的参数化 dtype(‘NA[f8,InfNan]’)可用于在不进行额外操作的情况下获得这些语义。

像这样的手动遍历掩码数组

>>> a = np.arange(5., maskna=True)
>>> a[3] = np.NA
>>> a
array([ 0.,  1.,  2., NA,  4.], maskna=True)
>>> for i in range(len(a)):
...     a[i] = np.log(a[i])
...
__main__:2: RuntimeWarning: divide by zero encountered in log
>>> a
array([       -inf,  0.        ,  0.69314718, NA,  1.38629436], maskna=True)

即使有掩码值也能工作,因为“a[i]”返回一个具有关联数据类型的 NA 对象,该对象可以被 ufunc 正确处理。

访问布尔掩码#

用于在掩码方法中实现缺失数据的掩码不能直接从 Python 访问。这部分是由于对 True 在掩码中应表示“缺失”还是“未缺失”的意见分歧。此外,直接暴露掩码会排除一种潜在的空间优化,即使用位级别而不是字节级别的掩码来获得八倍的内存使用改进。

要直接访问掩码,提供了两个函数。它们对带掩码的数组和 NA 位模式都等效,因此它们根据 NA 和可用值而不是掩码和未掩码值来指定。这些函数是“np.isna”和“np.isavail”,分别测试 NA 或可用值。

创建 NA 掩码数组#

创建具有 NA 掩码的数组的常用方法是将 maskna=True 关键字参数传递给其中一个构造函数。大多数创建新数组的函数都接受此参数,并在参数设置为 True 时生成所有元素都暴露的 NA 掩码数组。

还有两个标志指示和控制掩码数组中使用的掩码的性质。这些标志可用于添加掩码,或确保掩码不是对另一个数组掩码的视图。

第一个是“arr.flags.maskna”,对于所有掩码数组,它都是 True,并且可以设置为 True 来为没有掩码的数组添加掩码。

第二个是“arr.flags.ownmaskna”,如果数组拥有掩码的内存,则为 True;如果数组没有掩码,或对另一个数组的掩码具有视图,则为 False。如果此标志在掩码数组中设置为 True,则数组将创建掩码的副本,以便对掩码的进一步修改不会影响从中获取视图的原始掩码。

从列表构建时的 NA 掩码#

NA 掩码构造的初始设计是使所有构造都完全显式。这在与 NA 掩码数组进行交互式工作时非常繁琐,并且创建一个对象数组而不是 NA 掩码数组可能会非常令人惊讶。

因此,设计已更改为在从包含 NA 对象的列表创建数组时启用 NA 掩码。关于是默认创建 NA 掩码还是 NA 位模式可能存在一些争论,但由于时间限制,仅处理 NA 掩码是可行的,并且在 NumPy 中更全面地扩展 NA 掩码支持比启动另一个系统并最终得到两个不完整的系统要合理得多。

掩码实现细节#

掩码的内存顺序将始终与它所关联的数组的顺序相匹配。Fortran 风格的数组将具有 Fortran 风格的掩码,依此类推。

当获取一个带掩码的数组的视图时,该视图将拥有一个同样是原始数组掩码的视图。这意味着在视图中取消掩盖值也将在原始数组中取消掩盖它们,并且如果向数组添加掩码,则除非创建一个复制数据但不复制掩码的新数组,否则无法删除该掩码。

仍然可以通过先创建数组的视图,然后为该视图添加掩码来暂时将带掩码的数组视为没有掩码。通过创建多个视图并为每个视图分配掩码,可以同时以多个不同的掩码视图查看数据集。

新的 ndarray 方法#

添加到 numpy 命名空间的新函数是

np.isna(arr) [IMPLEMENTED]
    Returns a boolean array with True wherever the array is masked
    or matches the NA bitpattern, and False elsewhere

np.isavail(arr)
    Returns a boolean array with False wherever the array is masked
    or matches the NA bitpattern, and True elsewhere

添加到 ndarray 的新函数是

arr.copy(..., replacena=np.NA)
    Modification to the copy function which replaces NA values,
    either masked or with the NA bitpattern, with the 'replacena='
    parameter supplied. When 'replacena' isn't NA, the copied
    array is unmasked and has the 'NA' part stripped from the
    parameterized dtype ('NA[f8]' becomes just 'f8').

    The default for replacena is chosen to be np.NA instead of None,
    because it may be desirable to replace NA with None in an
    NA-masked object array.

    For future multi-NA support, 'replacena' could accept a dictionary
    mapping the NA payload to the value to substitute for that
    particular NA. NAs with payloads not appearing in the dictionary
    would remain as NA unless a 'default' key was also supplied.

    Both the parameter to replacena and the values in the dictionaries
    can be either scalars or arrays which get broadcast onto 'arr'.

arr.view(maskna=True) [IMPLEMENTED]
    This is a shortcut for
    >>> a = arr.view()
    >>> a.flags.maskna = True

arr.view(ownmaskna=True) [IMPLEMENTED]
    This is a shortcut for
    >>> a = arr.view()
    >>> a.flags.maskna = True
    >>> a.flags.ownmaskna = True

带缺失值的逐元素 ufunc#

作为实现的一部分,ufuncs 和其他操作将被扩展以支持掩码计算。因为这通常是一个有用的功能,即使在掩码数组的上下文之外,除了处理掩码数组之外,ufuncs 还将接受一个可选的“where=”参数,该参数允许使用布尔数组来选择计算应该在哪里进行。

>>> np.add(a, b, out=b, where=(a > threshold))

拥有这个“where=”参数的一个好处是,它提供了一种无需创建掩码数组对象即可暂时处理带掩码对象的方法。在上例中,这只会对“where”子句中值为 True 的数组元素进行加法运算,并且“a”和“b”都不需要是掩码数组。

如果未指定“out”参数,使用“where=”参数将产生一个带掩码的结果数组,其中“where”子句值为 False 的地方都有缺失值。

对于布尔运算,R 项目对 logical_and 和 logical_or 进行特殊处理,以便 logical_and(NA, False) 为 False,logical_or(NA, True) 为 True。另一方面,0 * NA 不是 0,但在这里 NA 可能代表 Inf 或 NaN,在这种情况下,0 * 底层值也不会是 0。

对于 NumPy 逐元素 ufuncs,设计将不支持掩码的输出同时依赖于输入的掩码和值的能力。然而,NumPy 1.6 的 nditer 使得编写外观和感觉与 ufuncs 相似但行为有所不同的独立函数变得相当容易。函数 logical_and 和 logical_or 可以移至独立函数对象中,这些对象向后兼容当前 ufuncs。

带缺失值的归约 ufunc#

像“sum”、“prod”、“min”和“max”这样的归约操作将一致地处理一个掩码值存在但其值未知的概念。

将为那些可以适当解释它的函数添加一个可选参数“skipna=”,以如同仅存在未掩码值一样进行操作。

当“skipna=True”时,当所有输入值都被掩盖时,“sum”和“prod”将分别产生加法和乘法单位元,而“min”和“max”将产生掩盖值。像“mean”和“std”这样需要计数的统计操作,如果“skipna=True”,也将使用未掩盖的值计数来进行计算,并在所有输入都被掩盖时产生掩盖值。

一些例子

>>> a = np.array([1., 3., np.NA, 7.], maskna=True)
>>> np.sum(a)
array(NA, dtype='<f8', maskna=True)
>>> np.sum(a, skipna=True)
11.0
>>> np.mean(a)
NA(dtype='<f8')
>>> np.mean(a, skipna=True)
3.6666666666666665

>>> a = np.array([np.NA, np.NA], dtype='f8', maskna=True)
>>> np.sum(a, skipna=True)
0.0
>>> np.max(a, skipna=True)
array(NA, dtype='<f8', maskna=True)
>>> np.mean(a)
NA(dtype='<f8')
>>> np.mean(a, skipna=True)
/home/mwiebe/virtualenvs/dev/lib/python2.7/site-packages/numpy/core/fromnumeric.py:2374: RuntimeWarning: invalid value encountered in double_scalars
  return mean(axis, dtype, out)
nan

函数“np.any”和“np.all”需要一些特殊的考虑,就像 logical_and 和 logical_or 一样。也许描述它们行为的最佳方式是通过一系列示例

>>> np.any(np.array([False, False, False], maskna=True))
False
>>> np.any(np.array([False, np.NA, False], maskna=True))
NA
>>> np.any(np.array([False, np.NA, True], maskna=True))
True

>>> np.all(np.array([True, True, True], maskna=True))
True
>>> np.all(np.array([True, np.NA, True], maskna=True))
NA
>>> np.all(np.array([False, np.NA, True], maskna=True))
False

由于“np.any”是“np.logical_or”的归约,而“np.all”是“np.logical_and”的归约,因此它们拥有像其他类似的归约函数一样的“skipna=”参数是合理的。

参数化 NA 数据类型#

掩码数组不是处理缺失数据的唯一方法,一些系统通过定义特殊的“NA”值来处理问题,表示缺失的数据。这与 NaN 浮点值不同,NaN 是无效浮点计算值的结果,但许多人为此目的使用 NaN。

在 IEEE 浮点值的情况下,可以使用一个特定的 NaN 值(有许多种)来表示“NA”,这与 NaN 不同。对于有符号整数,一个合理的方法是使用可存储的最小值,它没有对应的正值。对于无符号整数,最大存储值似乎最合理。

为了提供一个通用机制,参数化类型机制比创建单独的 nafloat32、nafloat64、naint64、nauint64 等 dtype 更具吸引力。如果这被视为处理掩码的一种替代方法,只是没有值保留,那么这种参数化类型可以以一种特殊的方式与掩码一起工作,在运行时产生一个值+掩码的组合,并使用与掩码数组系统完全相同的计算基础设施。这使得人们无需为每个 ufunc 和每个 na* dtype 编写特殊情况代码,而当构建每个 na* dtype 的独立实现时,这是很难避免的。

跨基本类型保留 NA 位模式的可靠转换也需要考虑。即使在双精度到单精度的简单情况下,硬件也支持这一点,但 NA 值仍会丢失,因为 NaN 的有效负载通常不被保留。能够为相同底层类型指定不同位掩码的能力也需要正确转换。通过定义良好的接口将(值、标志)对进行转换/从(值、标志)对进行转换,可以轻松地通用支持这一点。

这种方法还为 IEEE 浮点数提供了一些细微变化的机遇。默认情况下,将使用一个精确的位模式,即一个不会由硬件浮点运算生成的有效负载的静默 NaN。R 所做的选择可以是这个默认值。

此外,有时将所有 NaN 都视为缺失值可能很方便。这需要一个稍微复杂的映射来将浮点值转换为掩码/值组合,并且转换回时总是会产生 NumPy 使用的默认 NaN。最后,将 NaN 和 Inf 都视为缺失值只是 NaN 版本的一个细微变化。

字符串需要稍有不同的处理,因为它们的大小可能不定。一种方法是使用一个由前 32 个 ASCII/Unicode 值之一组成的单字符信号。这里有许多可能的用于此的值,例如 0x15“否定确认”或 0x10“数据链路转义”。

Object dtype 有一个显而易见的信号,即 np.NA 单例本身。任何具有对象语义的 dtype 都无法定制此功能,因为指定位模式仅适用于纯二进制数据,而不适用于具有对象构造和析构语义的数据。

结构体 dtypes 更像是核心的原始 dtype,与这种参数化 NA 功能 dtype 类似。不能将它们作为参数化 NA-dtype 的参数。

dtype 名称将像 datetime64 按元数据单位参数化一样进行参数化。使用哪个名称可能需要一些讨论,但“NA”似乎是一个合理的选择。具有默认缺失值位模式的这些 dtypes 将看起来像 np.dtype(‘NA[float32]’)、np.dtype(‘NA[f8]’)或 np.dtype(‘NA[i64]’)。

要覆盖表示缺失值的位模式,可以给出一个十六进制无符号整数格式的原始值,在上述浮点数的特殊情况下,可以提供特殊字符串。一些情况下的默认值,以这种形式显式写出,则为

np.dtype('NA[?,0x02]')
np.dtype('NA[i4,0x80000000]')
np.dtype('NA[u4,0xffffffff]')
np.dtype('NA[f4,0x7f8007a2')
np.dtype('NA[f8,0x7ff00000000007a2') (R-compatible bitpattern)
np.dtype('NA[S16,0x15]') (using the NAK character as the signal).

np.dtype('NA[f8,NaN]') (for any NaN)
np.dtype('NA[f8,InfNaN]') (for any NaN or Inf)

当没有指定参数时,将创建一个灵活的 NA dtype,它本身不能保存值,但会在 np.astype 等函数中符合输入类型。dtype ‘f8’ 映射到 ‘NA[f8]’,而 [(‘a’, ‘f4’), (‘b’, ‘i4’)] 映射到 [(‘a’, ‘NA[f4]’), (‘b’, ‘NA[i4]’)]。因此,要用 ‘NA[f8]’ 查看‘f8’数组‘arr’的内存,可以指定 arr.view(dtype=’NA’)。

未来扩展到多 NA 有效负载#

SAS 和 Stata 等软件包都支持多种不同的“NA”值。这允许指定一个值的原因不同,例如家庭作业未完成是因为狗吃了它,还是因为学生生病了。在这些包中,不同的 NA 值具有线性排序,指定了不同的 NA 值如何组合在一起。

在 C 实现细节部分,掩码的设计使得带有有效负载的掩码是 NumPy 布尔类型的严格超集,而布尔类型的有效负载仅为零。不同的有效负载与“min”操作结合。

未来化设计的重点是确保 C ABI 级别的选择和 Python API 级别的选择能够自然地过渡到多 NA 支持。以下是多 NA 支持的一种可能形式

>>> a = np.array([np.NA(1), 3, np.NA(2)], maskna='multi')
>>> np.sum(a)
NA(1, dtype='<i4')
>>> np.sum(a[1:])
NA(2, dtype='<i4')
>>> b = np.array([np.NA, 2, 5], maskna=True)
>>> a + b
array([NA(0), 5, NA(2)], maskna='multi')

此 NEP 的设计不区分来自 NA 掩码的 NA 和来自 NA dtype 的 NA。这两种在计算中都被同等对待,掩码优先于 NA dtypes。

>>> a = np.array([np.NA, 2, 5], maskna=True)
>>> b = np.array([1, np.NA, 7], dtype='NA')
>>> a + b
array([NA, NA, 12], maskna=True)

多 NA 方法允许通过为不同类型分配不同的有效负载来区分这些 NA。如果我们扩展“skipna=”参数以接受有效负载列表而不是 True/False,则可以这样做

>>> a = np.array([np.NA(1), 2, 5], maskna='multi')
>>> b = np.array([1, np.NA(0), 7], dtype='NA[f4,multi]')
>>> a + b
array([NA(1), NA(0), 12], maskna='multi')
>>> np.sum(a, skipna=0)
NA(1, dtype='<i4')
>>> np.sum(a, skipna=1)
7
>>> np.sum(b, skipna=0)
8
>>> np.sum(b, skipna=1)
NA(0, dtype='<f4')
>>> np.sum(a+b, skipna=(0,1))
12

与 numpy.ma 的区别#

numpy.ma 使用的计算模型并不严格遵循 NA 或 IGNORE 模型。本节举例说明了这些差异如何影响简单计算。这些信息对于帮助用户在系统之间导航将非常重要,因此可能应该在文档中以表格形式总结。

>>> a = np.random.random((3, 2))
>>> mask = [[False, True], [True, True], [False, False]]
>>> b1 = np.ma.masked_array(a, mask=mask)
>>> b2 = a.view(maskna=True)
>>> b2[mask] = np.NA

>>> b1
masked_array(data =
 [[0.110804969841 --]
 [-- --]
 [0.955128477746 0.440430735546]],
             mask =
 [[False  True]
 [ True  True]
 [False False]],
       fill_value = 1e+20)
>>> b2
array([[0.110804969841, NA],
       [NA, NA],
       [0.955128477746, 0.440430735546]],
       maskna=True)

>>> b1.mean(axis=0)
masked_array(data = [0.532966723794 0.440430735546],
             mask = [False False],
       fill_value = 1e+20)

>>> b2.mean(axis=0)
array([NA, NA], dtype='<f8', maskna=True)
>>> b2.mean(axis=0, skipna=True)
array([0.532966723794 0.440430735546], maskna=True)

对于 np.mean 等函数,当“skipna=True”时,所有 NA 的行为与空数组一致

>>> b1.mean(axis=1)
masked_array(data = [0.110804969841 -- 0.697779606646],
             mask = [False  True False],
       fill_value = 1e+20)

>>> b2.mean(axis=1)
array([NA, NA, 0.697779606646], maskna=True)
>>> b2.mean(axis=1, skipna=True)
RuntimeWarning: invalid value encountered in double_scalars
array([0.110804969841, nan, 0.697779606646], maskna=True)

>>> np.mean([])
RuntimeWarning: invalid value encountered in double_scalars
nan

特别要注意的是,numpy.ma 通常会跳过被掩盖的值,但当所有值都被掩盖时会返回被掩盖的值,而“skipna=”参数在所有值都为 NA 时返回零,以与 np.sum([]) 的结果保持一致。

>>> b1[1]
masked_array(data = [-- --],
             mask = [ True  True],
       fill_value = 1e+20)
>>> b2[1]
array([NA, NA], dtype='<f8', maskna=True)
>>> b1[1].sum()
masked
>>> b2[1].sum()
NA(dtype='<f8')
>>> b2[1].sum(skipna=True)
0.0

>>> np.sum([])
0.0

布尔索引#

使用包含 NA 的布尔数组进行索引,根据 NA 抽象没有一致的解释。例如

>>> a = np.array([1, 2])
>>> mask = np.array([np.NA, True], maskna=True)
>>> a[mask]
What should happen here?

由于 NA 代表一个有效但未知的值,并且它是一个布尔值,它有两种可能的底层值

>>> a[np.array([True, True])]
array([1, 2])
>>> a[np.array([False, True])]
array([2])

改变的是输出数组的长度,而不是本身可以被 NA 替换的东西。出于这个原因,至少在最初,NumPy 将为此类情况引发异常。

另一种可能性是增加不一致性,并遵循 R 使用的方法。也就是说,产生以下结果

>>> a[mask]
array([NA, 2], maskna=True)

如果在用户测试中,出于实际原因发现这很有必要,那么即使它不一致,也应该添加该功能。

PEP 3118#

PEP 3118 没有掩码机制,因此带有掩码的数组将无法通过此接口访问。同样,它不支持具有 NA 或 IGNORE 位模式的数据类型规范,因此参数化的 NA 数据类型也将无法通过此接口访问。

如果 NumPy 确实允许通过 PEP 3118 进行访问,那将以一种非常破坏性的方式规避缺失值抽象。其他库将尝试使用掩码数组,并在没有同时获得掩码的访问权限或意识到掩码和数据共同遵循的缺失值抽象的情况下,静默地访问数据。

Cython#

Cython 使用 PEP 3118 来处理 NumPy 数组,因此目前它将简单地拒绝处理它们,如“PEP 3118”部分所述。

为了正确支持 NumPy 缺失值,Cython 需要以某种方式进行修改以添加此支持。最有可能的实现方式是将其与支持 np.nditer 一起包含,np.nditer 最有可能进行增强,以便更容易编写缺失值算法。

硬掩码#

numpy.ma 实现有一个“hardmask”功能,它可以阻止通过赋值来取消掩码值。这将是一个内部数组标志,命名类似于“arr.flags.hardmask”。

如果实现了 hardmask 功能,布尔索引可以返回一个 hardmasked 数组,而不是一个具有任意 C 顺序选择的展平数组,就像它目前所做的那样。虽然这显著改进了数组的抽象,但它不是一个兼容的更改。

共享掩码#

numpy.ma 的一个功能称为“共享掩码”。

https://docs.scipy.org.cn/doc/numpy/reference/maskedarray.baseclass.html#numpy.ma.MaskedArray.sharedmask

此功能无法在没有直接违反缺失值抽象的情况下,通过掩码实现缺失值来支持。如果相同的掩码内存由两个数组“a”和“b”共享,则向“a”中被掩码的元素赋值将同时取消掩码“b”中具有匹配索引的元素。因为这并没有同时为“b”中的该元素赋值,所以这违反了抽象。出于这个原因,掩码表示的缺失值实现将不支持共享掩码。

这与查看支持掩码缺失值的数组的视图时的情况略有不同,此时会同时查看掩码和数据的视图。结果是两个共享相同掩码内存和相同数据内存的视图,这仍然保留了缺失值抽象。

与现有 C API 使用的交互#

确保使用 C API 的现有代码(无论是用 C、C++ 还是 Cython 编写的)做一些合理的事情是此实现的重要目标。通用策略是使不明确告知 numpy 支持 NA 掩码的现有代码引发异常。人们使用几种不同的访问模式来获取 NumPy 数组数据,在这里我们检查其中一些模式,看看 NumPy 可以做什么。这些示例是通过在 Google 上搜索 NumPy C API 数组访问找到的。

NumPy 文档 - 如何扩展 NumPy#

https://docs.scipy.org.cn/doc/numpy/user/c-info.how-to-extend.html#dealing-with-array-objects

该页面有一个名为“处理数组对象”的部分,其中提供了一些关于如何从 C 访问 NumPy 数组的建议。在接受数组时,它建议的第一步是使用 PyArray_FromAny 或在此函数之上构建的宏,因此遵循此建议的代码在给定无法处理的 NA 掩码数组时会正确失败。

处理方式是 PyArray_FromAny 需要一个特殊标志 NPY_ARRAY_ALLOWNA,然后才能允许 NA 掩码数组通过。

https://docs.scipy.org.cn/doc/numpy/reference/c-api.array.html#NPY_ARRAY_ALLOWNA

不遵循此建议,而是仅调用 PyArray_Check() 来验证它是否为 ndarray 并检查一些标志的代码,将静默地产生不正确的结果。这种代码风格没有提供 NumPy 说“嘿,这个数组很特别”的机会,因此与惰性求值、派生数据类型等未来想法也不兼容。

来自 Cython 网站的教程#

https://cython-docs.pythonlang.cn/src/tutorial/numpy.html

本教程给出了一个卷积示例,并且所有示例在输入包含 NA 值时都会以 Python 异常失败。

在引入任何 Cython 类型注解之前,代码的功能与解释器中的等效 Python 代码一样。

引入类型信息时,是通过 numpy.pxd 进行的,它定义了 ndarray 声明和 PyArrayObject * 之间的映射。在底层,这映射到 __Pyx_ArgTypeTest,它直接比较 Py_TYPE(obj) 和 ndarray 的 PyTypeObject。

然后代码进行一些数据类型比较,并使用常规的 Python 索引来访问数组元素。此 Python 索引仍通过 Python API 进行,因此 NumPy 中的 NA 处理和错误检查仍然可以正常工作,并在输入包含无法放入输出数组的 NA 时失败。在这种情况下,尝试将 NA 转换为要设置在输出中的整数时会失败。

下一版本代码引入了更高效的索引。这基于 Python 的缓冲区协议运行。这会导致 Cython 调用 __Pyx_GetBufferAndValidate,后者调用 __Pyx_GetBuffer,后者调用 PyObject_GetBuffer。此调用为 NumPy 提供了机会,如果输入是具有 NA 掩码的数组,则引发异常,而 Python 缓冲区协议不支持这一点。

数值 Python - JPL 网站#

http://dsnra.jpl.nasa.gov/software/Python/numpydoc/numpy-13.html

该文档来自 2001 年,因此无法反映最新的 NumPy,但它是 Google 搜索“numpy c api example”时的第二条结果。

文档的第一个示例,“一个简单的例子”,实际上在没有 NA 支持的情况下对于最新的 NumPy 已经无效。特别是,如果数据未对齐或字节序不同,它可能会崩溃或产生不正确的结果。

文档接下来的内容是引入 PyArray_ContiguousFromObject,它为 NumPy 提供了在 NA 掩码数组使用时引发异常的机会,因此后续代码将按预期引发异常。

C 实现细节#

首先实现的是数组掩码,因为它是更通用的方法。掩码本身是一个数组,但由于它不打算直接从 Python 访问,所以它本身不会是完整的 ndarray。掩码的形状始终与其附加的数组相同,因此不需要它自己的形状。但是,对于具有结构化数据类型的数组,掩码的数据类型将与普通的布尔值不同,因此它需要自己的数据类型。这为 PyArrayObject 带来了以下添加:

/*
 * Descriptor for the mask dtype.
 *   If no mask: NULL
 *   If mask   : bool/uint8/structured dtype of mask dtypes
 */
PyArray_Descr *maskna_dtype;
/*
 * Raw data buffer for mask. If the array has the flag
 * NPY_ARRAY_OWNMASKNA enabled, it owns this memory and
 * must call PyArray_free on it when destroyed.
 */
npy_mask *maskna_data;
/*
 * Just like dimensions and strides point into the same memory
 * buffer, we now just make the buffer 3x the nd instead of 2x
 * and use the same buffer.
 */
npy_intp *maskna_strides;

这些字段可以通过内联函数访问

PyArray_Descr *
PyArray_MASKNA_DTYPE(PyArrayObject *arr);

npy_mask *
PyArray_MASKNA_DATA(PyArrayObject *arr);

npy_intp *
PyArray_MASKNA_STRIDES(PyArrayObject *arr);

npy_bool
PyArray_HASMASKNA(PyArrayObject *arr);

必须为数组标志添加 2 或 3 个标志,用于请求 NA 掩码和测试它们

NPY_ARRAY_MASKNA
NPY_ARRAY_OWNMASKNA
/* To possibly add in a later revision */
NPY_ARRAY_HARDMASKNA

为了方便检测 NA 支持以及数组是否包含任何缺失值,我们添加了以下函数:

PyDataType_HasNASupport(PyArray_Descr* dtype)

如果这是 NA 数据类型,或者是一个所有字段都支持 NA 的结构化数据类型,则返回 true。

PyArray_HasNASupport(PyArrayObject* obj)

如果数组数据类型支持 NA,或者数组具有 NA 掩码,则返回 true。

PyArray_ContainsNA(PyArrayObject* obj)

如果数组不支持 NA,则返回 false。如果数组支持 NA 且数组中存在 NA,则返回 true。

int PyArray_AllocateMaskNA(PyArrayObject* arr, npy_bool ownmaskna, npy_bool multina)

为数组分配一个 NA 掩码,如果请求则确保所有权,并在 multina 为 True 时使用 NPY_MASK 而不是 NPY_BOOL 作为数据类型。

掩码二进制格式#

掩码本身的格式设计用于指示元素是否被掩码,以及包含一个有效负载,以便将来可以使用多个具有不同有效负载的 NA。起初,我们将仅使用有效负载 0。

掩码的数据类型为 npy_uint8,位 0 用于指示值是否被掩码。如果 ((m&0x01) == 0),则元素被掩码,否则它未被掩码。其余的位是有效负载,即 (m>>1)。结合带有效负载的掩码的约定是较小的有效负载会传播。此设计为被掩码的元素提供了 128 个有效负载值,为未被掩码的元素提供了 128 个有效负载值。

这种方法的一个主要好处是 npy_bool 也可以用作掩码,因为它取值为 0 表示 False,1 表示 True。此外,npy_bool 的有效负载(始终为零)会覆盖所有其他可能的有效负载。

由于设计涉及为掩码提供自己的数据类型,因此我们可以区分使用单个 NA 值(npy_bool 掩码)进行掩码和使用多 NA(npy_uint8 掩码)进行掩码。初始实现将仅支持 npy_bool 掩码。

被否决的一个想法是允许掩码 + 有效负载的组合成为一个简单的“min”操作。这可以通过将有效负载放入位 0 到 6 来完成,以便有效负载为 (m&0x7f),并使用位 7 作为掩码标志,因此 ((m&0x80) == 0) 表示元素被掩码。这个选择被否决的主要原因是它使掩码与布尔值完全不同,而不是严格的超集。

C 迭代器 API 更改:带掩码的迭代#

对于带掩码的迭代和计算,无论是在缺失值的情况下,还是当掩码用作 ufuncs 中的“where=”参数时,扩展 nditer 都是公开此功能的最自然的方式。

掩码操作需要与转换、对齐以及导致值复制到临时缓冲区的任何其他操作配合使用,这由 nditer 很好地处理,但在该上下文之外很难完成。

首先,我们描述用于在缺失值上下文之外使用掩码的迭代,然后是包含缺失值支持的特性。

迭代器掩码特性#

我们添加了几个新的每个操作数标志:

NPY_ITER_WRITEMASKED

指示从缓冲区到数组的任何复制都是被掩码的。这是必需的,因为 READWRITE 模式可能会破坏数据,如果一个浮点数组被当作一个整数数组,那么复制到缓冲区再复制回来就会截断为整数。对于读取,没有提供类似的标志,因为可能无法提前知道掩码,并且将所有内容复制到缓冲区永远不会破坏数据。

使用迭代器的代码只能写入未被指定掩码掩码的值,否则结果将取决于是否启用了缓冲。

NPY_ITER_ARRAYMASK

指示此数组是用于将任何 WRITEMASKED 参数从缓冲区复制回数组的布尔掩码。只能有一个这样的掩码,也不能有虚拟掩码。

作为特殊情况,如果同时指定了 NPY_ITER_USE_MASKNA 标志,则使用操作数的掩码而不是操作数本身。如果操作数没有掩码但基于 NA 数据类型,则迭代器公开的掩码在从缓冲区复制到数组时会转换为 NA 位模式。

NPY_ITER_VIRTUAL

指示此操作数不是数组,而是为内部迭代代码即时创建的。这会分配足够的缓冲区空间供代码读/写数据,但没有实际的数组支持数据。当与 NPY_ITER_ARRAYMASK 结合使用时,可以创建“虚拟掩码”,指定哪些值未被掩码,而无需创建完整的掩码数组。

迭代器 NA 数组特性#

我们添加了几个新的每个操作数标志:

NPY_ITER_USE_MASKNA

如果操作数具有 NA 数据类型、NA 掩码或两者兼有,则此标志会在操作数列表末尾添加一个新的虚拟操作数,该操作数会迭代特定操作数的掩码。

NPY_ITER_IGNORE_MASKNA

如果操作数具有 NA 掩码,默认情况下迭代器将引发异常,除非指定了 NPY_ITER_USE_MASKNA。此标志禁用该检查,并用于您已经通过 PyArray_ContainsNA 函数检查所有数组元素都不是 NA 的情况。

如果数据类型是 NA 数据类型,此标志还将从数据类型中剥离 NA 属性,显示不支持 NA 的数据类型。

否决的替代方案#

添加额外内存用于 NA 标志的参数化数据类型#

除了为数组添加单独的掩码之外,另一种替代方法是引入参数化类型,它接受一个原始数据类型作为参数。数据类型“i8”将变为“maybe[i8]”,并将一个字节标志附加到数据类型中,以指示该值是否为 NA。

此方法会产生比保留单独掩码更大或相等的内存开销,但具有更好的局部性。为了保持数据类型对齐,一个“i8”需要 16 字节才能保持正确对齐,与单独保留掩码的 12.5% 开销相比,开销为 100%。

致谢#

除了来自 Travis Oliphant 和 Enthought 其他人的反馈外,本 NEP 在 NumPy-Discussion 邮件列表的广泛反馈基础上进行了修订。参与讨论的人员有:

  • Nathaniel Smith

  • Robert Kern

  • Charles Harris

  • Gael Varoquaux

  • Eric Firing

  • Keith Goodman

  • Pierre GM

  • Christopher Barker

  • Josef Perktold

  • Ben Root

  • Laurent Gautier

  • Neal Becker

  • Bruce Southey

  • Matthew Brett

  • Wes McKinney

  • Lluís

  • Olivier Delalleau

  • Alan G Isaac

    1. Antero Tammi

  • Jason Grout

  • Dag Sverre Seljebotn

  • Joe Harrington

  • Gary Strangman

  • Chris Jordan-Squire

  • Peter

如果我遗漏了任何人,我深表歉意。