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 位浮点表示在位级别兼容,但不会保留隐藏值,实际上要求从基础数据类型中至少窃取一位模式才能表示缺失值 NA。

这两种解决方案都具有通用性,因为它们都可以非常轻松地与自定义数据类型一起使用,其中掩码解决方案不需要任何工作,而位模式解决方案则要求选择要牺牲的位模式。

缺失数据的定义#

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

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

对于希望其他解释为默认值的人而言,其他地方为使用_numpy_ufunc_成员函数自定义子类 ufunc 行为而提出的一种机制将允许创建具有不同默认值的子类。

尚未存在的已知数据 (NA)#

此方法在 R 项目中得到采用,将缺失元素定义为具有某个值但这些值未知或不可用的 (NA) 值。此提案将这种行为作为涉及所有缺失值的运算的默认行为。

在此解释中,具有缺失输入的近乎所有计算将生成一个缺失输出。例如,如果“a”仅包含一个缺失元素,则“sum(a)”将生成一个缺失值。当输出值不依赖于其中一个输入时,输出一个非 NA 值是合理的,例如 logical_and(NA, False) == False。

此解释中明确定义了一些较复杂的算术运算(例如矩阵乘法),结果应与缺失值是 NaNs 时相同。实际将此类操作实现到理论极限可能不值得,并且在许多情况下,引发异常或返回所有缺失值可能比进行精确计算更为可取。

不存在或正在跳过的数据 (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 相同。

忽略 (忽略/跳过)

一个在计算时应视为该处没有或不可能有值的占位符。对于求和,这意味着将该值作为 0;对于乘积,这意味着将该值作为 1。就好像数组以某种方式压缩,不包括该元素一样。

位模式

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

掩码

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

numpy.ma

屏蔽数组的一种特定形式的现有实现,该实现是 NumPy 代码库的一部分。

Python API

NumPy 中用于使用缺失值暴露给 Python 代码的所有接口机制。此 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, <inaccessible>, 7.0] / mask [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。要掩盖这些值,可以对 “u[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

含缺失值的逐元素 ufuncs#

作为实现的一部分,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,但对其行为有偏差。可以将函数 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

与 logical_and 和 logical_or 一样,“np.any”和“np.all”函数也需要特殊考虑。也许描述其行为的最佳方法是一系列示例。

>>> 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 等数据类型有吸引力得多。如果将其视为处理掩码的另一种替代方式,但无需保留值,此参数化类型可以与掩码以特殊方式配合使用,以实时生成值 + 掩码组合,并使用与掩码数组系统完全相同的计算基础设施。这避免了为每个 ufunc 和每个 na* 数据类型编写特殊情况下代码的需要,这是在为每个 na* 数据类型构建单独的独立数据类型实现时难以避免的。

可靠的转换也要求在原始类型中保留 NA 位模式。即使在 double -> float 的简单情况下(硬件支持此情况),NA 值也将会丢失,因为 NaN 有效负载通常不会保留。对于相同底层类型指定的不同位掩码具备正确进行转换的能力也需要。通过定义明确的接口将 (值、标志) 对转换为/从 (值、标志) 对,就有可能以通用方式简单地加以支持。

此方法还提供了一些针对 IEEE 浮点数进行细微变化的机会。默认情况下,会使用一个精确位模式、带有一个有效负载的静默 NaN(硬件浮点运算不会生成此有效负载)。R 已选择的条件可能是此默认条件。

此外,有时将所有 NaN 视为缺失值可能也不错。这需要略微复杂的映射才能将浮点值转换成掩码/值组合,而转换回来总是会生成 NumPy 使用的默认 NaN。最后,将 NaN 和 Inf 都视为缺失值仅仅是 NaN 版本的细微变化。

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

对象数据类型具有明显的信号,即 np.NA 单例本身。具有对象语义的任何数据类型都无法进行此项自定义,因为指定位模式仅适用于常规二进制数据,而不适用于具有对象构建和销毁语义的数据。

结构数据类型更像是核心原始数据类型,这与支持 NA 的参数化数据类型的方式相同。不可能将这些类型作为支持 NA 参数化数据类型的参数。

数据类型名称的参数化与 datetime64 根据元数据单位进行参数化的方式类似。需要针对要使用的名称进行一些讨论,但“NA”似乎是合理的选项。通过缺省的缺失值位模式,这些数据类型将类似于 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 数据类型,它本身不能保存值,但在“np.astype”等函数中会遵循输入类型。数据类型“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 布尔类型的严格超集,并且布尔类型仅具有零有效负载。不同的有效负载与“最小值”运算结合。

未来设计的关键部分在于确保 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。在计算中,这两者都以同等的方式得到处理,且掩码优于 NA 数据类型。

>>> 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 位模式指定 dtypes,因此带参数的 NA dtypes 也不会通过此接口访问。

如果 NumPy 确实允许通过 PEP 3118 访问,这将以非常破坏性的方式规避缺失值抽象。其他库会尝试使用掩码数组,并在不被发现的情况下获取数据,而不获取掩码或了解掩码和数据共同遵循的缺失值抽象。

Cython#

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

为了很好地支持 NumPy 缺失值,需要对 Cython 进行一些修改来添加此支持。最好的方法可能是将其与支持的 np.nditer 一起包含,它很可能会得到增强以使编写缺失值算法变得更容易。

Hard masks#

numpy.ma 实现具有“hardmask”特性,可防止值通过赋值而被取消掩码。这将是内部数组标志,命名为‘arr.flags.hardmask’之类的东西。

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

Shared masks#

numpy.ma 的一项特性称为“shared masks”。

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

在直接违反缺失值抽象的情况下,masked 实现的缺失值无法支持此特性。如果相同的掩码内存由两个数组‘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 说“嘿,此数组很特别”,因此也不兼容未来延迟评估、派生数据类型等想法。

Cython 网站教程#

https://cython-doc.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。这种调用为 numpy 提供了机会,如果输入为带 NA 掩码的数组(Python 缓冲协议不支持),它将引发一个异常。

数字 Python - JPL 网站#

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

本文档来自 2001 年,因此无法反映最新的 numpy,但它是在谷歌搜索“numpy c api示例”的第二次点击。

第一个示例,标题为“一个简单的示例”,事实上对最近的 numpy 已经无效,即使没有 NA 支持。特别是,如果数据错位或采用不同的字节序,则它可能崩溃或产生不正确的结果。

文档所做的下一件事是引入 PyArray_ContiguousFromObject,它给 numpy 提供了一个机会,在使用有 NA 掩码的数组时引发异常,因此稍后的代码会按要求引发异常。

C 实现细节#

要实现的第一个版本是数组掩码,因为它是更通用的方法。掩码本身是一个数组,但由于它的目的是从 Python 中无法直接访问,所以它本身不会是完整的 ndarray。掩码始终与其附着的数组具有相同的 shape,因此不需要自身的 shape。但是,对于具有结构化 dtype 的数组,掩码的 dtype 与直接的 bool 不同,因此确实需要它自己的 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 更改:使用掩码进行迭代#

对于在缺失值的情况下以及在掩码类似于 ufunc 中的“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 标记添加额外内存#

将单独的掩码添加到数组中的另一种替代方案是引入参数化类型,该类型采用原始 dtype 作为参数。dtype“i8”将变为“maybe[i8]”,并且会将一个字节标志追加到 dtype 来指示该值是 NA 还是非 NA。

此方法添加的内存开销大于或等于保存单独的掩码,但具有更好的局部性。为了使 dtype 对齐,一个“i8”将需要具有 16 个字节才能保持恰当的对齐,与单独保存的掩码的 12.5% 开销相比,这是 100% 的开销。

致谢#

除了 Travis Oliphant 和 Enthought 中的其他人提供的反馈之外,NumPy-Discussion 邮件列表中的大量反馈已对这项 NEP 进行了修改。参与讨论的人员如下

  • 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

如果我错过了任何人,我深表歉意。