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

作者:

Mark Wiebe <mwwiebe@gmail.com>

版权:

2011 年 Enthought, Inc 版权所有

许可证:

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

日期:

2011-06-23

状态:

已推迟

目录#

摘要#

对在 NumPy 中处理缺失数据感兴趣的用户通常会指向 ndarray 的掩码数组子类,称为“numpy.ma”。这个类有很多强烈依赖其功能的用户,但是那些习惯于 R 项目中缺失数据占位符“NA”的深度集成的人以及那些觉得编程接口具有挑战性或不一致的人往往不会使用它。

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

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

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

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

这两种解决方案都是通用的,因为它们可以非常容易地与自定义数据类型一起使用,对于掩码解决方案来说不需要任何工作,对于位模式解决方案来说,需要选择一个要牺牲的位模式。

缺失数据的定义#

为了能够对各种 NumPy 函数将要执行的计算产生直觉,必须应用一个关于缺失元素含义的一致的概念模型。找出人们在处理“缺失数据”时需要或想要的行为似乎很棘手,但我认为它可以归结为两个不同的想法,每个想法在内部都是自洽的。

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

对于希望将其他解释设为默认值的人,在其他地方提出的使用 _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 (不可用/传播)

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

IGNORE (忽略/跳过)

一个占位符,在计算中应被视为不存在或不可能存在值。对于求和,这意味着表现得好像该值为零,对于求积,这意味着表现得好像该值为一。就好像该数组以某种方式被压缩而不包含该元素一样。

bitpattern

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

mask

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

numpy.ma

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

Python API

所有暴露给 Python 代码的接口机制,用于在 NumPy 中使用缺失值。此 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] / 掩码 [Exposed, Exposed, Hidden, Exposed] 的数组,以及掩码和 NA 数据类型版本的值 [1.0, 2.0, <NA 位模式>, 7.0]。

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

>>> 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 分配给数组会掩盖该元素或为特定数据类型分配 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 位模式,则将此值复制到数据类型的 NA 形式也会使其变为 NA。

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

如果将 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’ 来实现。对于位模式方法,可以使用稍后部分描述的参数化数据类型(‘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#

作为实现的一部分,必须扩展 ufunc 和其他操作以支持掩码计算。由于这是一个普遍有用的功能,即使在掩码数组的上下文之外,除了使用掩码数组外,ufunc 还将采用一个可选的 ‘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 逐元素 ufunc,该设计将不支持输出的掩码同时取决于输入掩码和值的能力。但是,NumPy 1.6 nditer 使编写看起来和感觉都像 ufunc 的独立函数变得相当容易,但其行为与 ufunc 不同。函数 logical_and 和 logical_or 可以移动到与当前 ufunc 向后兼容的独立函数对象中。

带有缺失值的缩减 ufunc#

诸如 ‘sum’、‘prod’、‘min’ 和 ‘max’ 之类的缩减操作将与掩码值存在但其值未知这一概念保持一致地运行。

将向那些可以适当解释它的函数添加一个可选参数 ‘skipna=’,以便执行操作,就好像只存在未掩码的值一样。

当 ‘skipna=True’ 时,如果所有输入值都被掩码,则 ‘sum’ 和 ‘prod’ 将分别产生加法和乘法单位元,而 ‘min’ 和 ‘max’ 将产生掩码值。如果 ‘skipna=True’,则需要计数的统计操作(如 ‘mean’ 和 ‘std’)也将使用未掩码的值计数进行计算,并且当所有输入都被掩码时会产生掩码值。

一些示例

>>> 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 用于此目的。

在 IEEE 浮点值的情况下,可以使用特定的 NaN 值(其中有很多),用于 “NA”,与 NaN 不同。对于有符号整数,合理的方法是使用最小的可存储值,该值没有对应的正值。对于无符号整数,最大存储值似乎最合理。

为了提供通用机制,此参数化类型机制比创建单独的 nafloat32、nafloat64、naint64、nauint64 等 dtypes 更具吸引力。如果将其视为处理掩码的替代方法(除了没有值保留之外),则此参数化类型可以以特殊的方式与掩码一起工作,以动态生成值 + 掩码组合,并使用与掩码数组系统完全相同的计算基础结构。这允许人们避免为每个 ufunc 和每个 na* dtype 编写特殊情况代码,当为每个 na* dtype 构建单独的独立 dtype 实现时,这是很难避免的。

在基本类型之间保留 NA 位模式的可靠转换也需要考虑。即使在 double -> float 的简单情况下(硬件支持),NA 值也会丢失,因为 NaN 有效负载通常不会保留。为同一底层类型指定不同位掩码的能力也需要正确转换。通过明确定义的接口转换为/从 (value,flag) 对,可以很容易地实现通用支持。

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

此外,有时可能最好将所有 NaN 都视为缺失值。这需要更复杂的映射来将浮点值转换为掩码/值组合,并且转换回来将始终生成 NumPy 使用的默认 NaN。最后,将 NaN 和 Infs 都视为缺失值只是 NaN 版本的略微变化。

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

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

Struct 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,该 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 位模式指定 dtype,因此参数化的 NA dtype 也无法通过此接口访问。

如果 NumPy 允许通过 PEP 3118 访问,这将以一种非常有害的方式规避缺失值抽象。其他库会尝试使用屏蔽数组,并在不获取屏蔽或不了解屏蔽和数据共同遵循的缺失值抽象的情况下,静默地访问数据。

Cython#

Cython 使用 PEP 3118 来处理 NumPy 数组,因此目前它会像“PEP 3118”部分所述的那样,直接拒绝处理它们。

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

硬屏蔽#

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

如果实现了硬屏蔽功能,布尔索引可以返回硬屏蔽数组,而不是像当前那样返回具有 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

此页面有一个 “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 提供任何机会来说“嘿,这个数组很特殊”,因此也不兼容未来的延迟求值、派生 dtype 等想法。

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` 进行直接比较。

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

下一个版本的代码引入了更高效的索引。这基于 Python 的缓冲区协议进行操作。这会导致 Cython 调用 `__Pyx_GetBufferAndValidate`,该函数调用 `__Pyx_GetBuffer`,后者调用 `PyObject_GetBuffer`。如果输入是具有 NA 屏蔽的数组(Python 缓冲区协议不支持此操作),则此调用为 NumPy 提供了引发异常的机会。

数值 Python - JPL 网站#

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

本文档来自 2001 年,因此不反映最近的 NumPy,但它是 Google 上搜索 “numpy c api example” 的第二个结果。

它的第一个示例,标题为 “A simple example”,实际上即使没有 NA 支持,也对最近的 NumPy 无效。特别是,如果数据未对齐或具有不同的字节顺序,则可能会崩溃或产生不正确的结果。

文档的下一步是引入 `PyArray_ContiguousFromObject`,这为 NumPy 提供了在使用 NA 屏蔽数组时引发异常的机会,因此后面的代码将按预期引发异常。

C 实现细节#

要实现的第一个版本是数组屏蔽,因为这是一种更通用的方法。屏蔽本身是一个数组,但由于它永远不应该直接从 Python 访问,因此它本身不会是一个完整的 ndarray。屏蔽始终具有与其所附加的数组相同的形状,因此它不需要自己的形状。但是,对于具有结构 dtype 的数组,屏蔽将具有与直接的 bool 不同的 dtype,因此它需要自己的 dtype。这为 `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 dtype,或者每个字段都支持 NA 的结构 dtype,则返回 true。

`PyArray_HasNASupport(PyArrayObject* obj)`

如果数组 dtype 支持 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` 作为 dtype。

屏蔽二进制格式#

屏蔽本身的格式旨在指示元素是否被屏蔽,以及包含一个有效载荷,以便将来可以使用具有不同有效载荷的多个不同的 NA。最初,我们将只使用有效载荷 0。

屏蔽的类型为 `npy_uint8`,位 0 用于指示值是否被屏蔽。如果 `((m&0x01) == 0)`,则该元素被屏蔽,否则未被屏蔽。其余位是有效载荷,即 `(m>>1)`。将屏蔽与有效载荷组合的约定是,较小的有效载荷会传播。此设计为被屏蔽的元素提供 128 个有效载荷值,为未被屏蔽的元素提供 128 个有效载荷值。

这种方法的最大好处是 `npy_bool` 也可以作为掩码使用,因为它对于 False 取值为 0,对于 True 取值为 1。此外,`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%。

致谢#

除了来自 Enthought 的 Travis Oliphant 和其他人的反馈外,此 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

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