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

状态:

推迟

目录#

摘要#

对 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 旨在尽可能地符合 Python 风格并融入 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 对象,ufuncs 可以正确处理它。

访问布尔掩码#

在掩码方法中用于实现缺失数据的掩码不能直接从 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

带缺失值的逐元素 ufuncs#

作为实现的一部分,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 向后兼容的独立函数对象中。

带缺失值的归约 ufuncs#

归约操作,如 ‘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 用于此目的。

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

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

在基本类型之间保留 NA 位模式的可靠转换也需要考虑。即使在 double -> float 这种硬件支持的简单情况下,NA 值也会丢失,因为 NaN 负载通常不保留。为相同的底层类型指定不同位掩码的能力也需要正确转换。通过定义良好的将 (value,flag) 对相互转换的接口,这将变得通用且易于支持。

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

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

字符串需要略有不同的处理,因为它们可以是任意大小。一种方法是使用由前 32 个 ASCII/Unicode 值之一组成的一个字符信号。这里有许多可能的值可以使用,例如 0x15 '负确认 (Negative Acknowledgement)' 或 0x10 '数据链路转义 (Data Link Escape)'。

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 位模式的 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 可以做什么。这些示例是通过搜索 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 提供任何机会来提示“嘿,这个数组很特殊”,因此也不兼容未来的惰性求值、派生 dtype 等概念。

Cython 网站教程#

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

本教程提供了一个卷积示例,当输入包含 NA 值时,所有示例都会因 Python 异常而失败。

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

当引入类型信息时,它是通过 numpy.pxd 完成的,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。此调用使 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。掩码始终与它所附加的数组具有相同的形状,因此它不需要自己的形状。然而,对于带有结构体 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,则对 dtype 使用 NPY_MASK 而不是 NPY_BOOL。

掩码二进制格式#

掩码本身的格式旨在指示元素是否被掩码,并包含一个负载,以便将来可以使用具有不同负载的多个不同 NA。最初,我们将简单地使用负载 0。

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

这种方法的一个巨大好处是 npy_bool 也可以用作掩码,因为它取值为 0 表示 False,1 表示 True。此外,npy_bool 的负载(总是零)优于所有其他可能的负载。

由于设计涉及为掩码提供其自身的 dtype,我们可以区分使用单个 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 dtype,则迭代器暴露的掩码在从缓冲区复制到数组时会转换为 NA 位模式。

NPY_ITER_VIRTUAL

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

迭代器 NA 数组特性#

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

NPY_ITER_USE_MASKNA

如果操作数具有 NA dtype、NA 掩码或两者兼有,这将向操作数列表末尾添加一个新的虚拟操作数,该操作数迭代特定操作数的掩码。

NPY_ITER_IGNORE_MASKNA

如果操作数具有 NA 掩码,默认情况下迭代器将引发异常,除非指定了 NPY_ITER_USE_MASKNA。此标志禁用该检查,旨在用于首先使用 PyArray_ContainsNA 函数检查数组中所有元素均不为 NA 的情况。

如果 dtype 是 NA dtype,这还会从 dtype 中剥离 NA 属性,显示一个不支持 NA 的 dtype。

被拒绝的替代方案#

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

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

这种方法增加了大于或等于保留单独掩码的内存开销,但具有更好的局部性。为了保持 dtype 对齐,一个 ‘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

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