NEP 25 — 通过特殊数据类型支持 NA#

作者:

Nathaniel J. Smith <njs@pobox.com>

状态:

已推迟

类型:

标准追踪

创建日期:

2011-07-08

摘要#

背景:本 NEP 作为 NEP 12 的额外替代方案(NEP 24 是另一个替代方案)而编写,在编写时,其实现已合并到 NumPy 主分支中。

为了在整个缺失值/掩码数组/...的争论中取得更多进展,进行更技术性的讨论,确定我们 *可以* 达成一致的部分,似乎很有用。这是第二部分,旨在详细阐明如何使用特殊数据类型实现 NA。

基本原理#

普通值类似于整数或浮点数。缺失值是普通值的占位符,由于某种原因不可用。例如,在处理统计数据时,我们经常构建表格,其中每行代表一个项目,每列代表该项目的属性。例如,我们可能会选择一组人,并记录每个人的身高、年龄、教育水平和收入,然后将这些值放入表格中。但后来我们发现研究助理搞砸了,忘记记录其中一个人的年龄。我们也可以丢弃他们其余的数据,但这会造成浪费;即使是不完整的行,对于某些分析(例如,我们可以计算身高和收入的相关性)也仍然完全可用。处理此问题的传统方法是为缺失数据填入某个特定的无意义值,例如,将此人的年龄记录为 0。但这非常容易出错;我们以后在运行其他分析时可能会忘记这些特殊值,然后惊讶地发现婴儿的收入高于青少年。(在这种情况下,解决方案是只排除所有没有记录年龄的项目,但这并非通用解决方案;许多分析需要更巧妙的方法来处理缺失值。)因此,我们不使用像 0 这样的普通值,而是定义一个特殊的“缺失”值,写作“NA”,代表“不可用”(not available)。

在内存中表示此类值有几种可能的方法。例如,我们可以保留一个特定值(例如 0,或特定的 NaN,或最小负整数),然后确保该值在数组上的所有算术和其他操作中都得到特殊处理。另一种选择是在主数组旁边添加一个额外的掩码数组,用它来指示哪些值应被视为 NA,然后在执行计算时扩展我们的数组操作以检查此掩码数组。每种实现方法都有其优点和缺点,但在此我们只关注前者(基于值的)方法,将后者可能的添加留待未来讨论。这种方法的核心优势是 (1) 不增加额外的内存开销,(2) 使用现有文件存储格式可以轻松地将此类数组存储和检索到磁盘,(3) 允许与包含 NA 值的 R 数组进行二进制兼容,(4) 与使用 NaN 表示浮点数缺失的常见做法兼容,(5) dtype 已经是一个“奇怪事情可能发生”的地方——有各种各样的数据类型不像普通数字那样运行(包括结构体、Python 对象、固定长度字符串等),因此接受任意 NumPy 数组的代码已经必须准备好处理这些(即使只是通过检查并引发错误)。因此,添加更多新的数据类型对扩展作者的影响,要小于更改 ndarray 对象本身的影响。

NA 值的基本语义如下:和任何其他值一样,它们必须由数组的数据类型支持——你不能将浮点数存储在 dtype=int32 的数组中,也不能将 NA 存储在其中。你需要一个 dtype=NAint32 或类似的数据类型数组(确切语法待定)。否则,NA 值的行为与任何其他值完全相同。特别是,你可以对它们应用算术函数等。默认情况下,任何将 NA 作为参数的函数,无论其他参数的值如何,始终返回 NA。这确保了如果我们尝试计算收入与年龄的相关性,我们将得到“NA”,这意味着“鉴于某些条目可能是任意值,结果也可能是任意值”。这提醒我们花点时间思考如何重新措辞问题,使其更具意义。此外,为了方便起见,当你确实只想计算已知年龄和收入之间的相关性时,可以通过在函数调用中添加一个参数来启用此行为。

对于浮点计算,NA 和 NaN 具有(几乎?)相同的行为。但它们代表不同的事物——NaN 表示像 0/0 这样的无效计算,NA 表示不可用的值——区分这些事物很有用,因为在某些情况下它们应该被区别对待。(例如,插补过程应该用插补值替换 NA,但可能应该保留 NaN 不变。)而且,我们不能将 NaN 用于整数、字符串或布尔值,所以无论如何都需要 NA,一旦我们对所有这些类型都支持了 NA,为了保持一致性,我们也不妨支持浮点数。

总体策略#

NumPy 已经有一个通用机制,用于定义新的数据类型并将其插入,以便它们得到 ndarray、类型转换机制、ufuncs 等的支持。原则上,我们可以只使用这些现有接口来实现 NA 数据类型。但我们不想这样做,因为从头开始定义所有这些新的 ufunc 循环等会非常麻烦,特别是因为所需的基本功能在所有情况下都是相同的。因此,我们需要一些 NA 的通用功能——但最好不要将其硬编码为一套特殊的“NA 类型”,因为用户很可能希望定义具有自己 NA 值的新自定义数据类型,并使其与其余的 NA 机制良好集成。因此,我们的策略是避免“中间层错误”,通过公开一些用于在不同情况下进行通用 NA 处理的代码,数据类型可以根据需要选择性地使用或不使用这些代码。

一些用例示例
  1. 我们希望定义一种数据类型,其行为与 int32 完全相同,只是将最小负值视为 NA。

  2. 我们希望定义一个参数化的数据类型来表示分类数据,并且用于 NA 的位模式取决于定义的类别数量,因此我们的代码需要主动处理它,而不是简单地委托给标准机制。

  3. 我们希望定义一种数据类型,其行为类似于长度为 10 的字符串并支持 NA。由于我们的字符串可能包含任意二进制值,我们实际上希望为其分配 11 字节,其中第一个字节作为标志指示此字符串是否为 NA,其余字节包含字符串内容。

  4. 我们希望定义一种数据类型,允许多种不同类型的 NA 数据,它们打印方式不同,并且可以通过我们定义的新 ufunc is_na_of_type(...) 来区分,但在其他方面利用通用 NA 机制进行大多数操作。

数据类型 C 级 API 扩展#

PyArray_Descr 结构体新增以下字段

void * NA_value;
PyArray_Descr * NA_extends;
int NA_extends_offset;

定义了以下新的标志值

NPY_NA_AUTO_ARRFUNCS
NPY_NA_AUTO_CAST
NPY_NA_AUTO_UFUNC
NPY_NA_AUTO_UFUNC_CHECKED
NPY_NA_AUTO_ALL /* the above flags OR'ed together */

PyArray_ArrFuncs 结构体新增以下字段

void (*isna)(void * src, void * dst, npy_intp n, void * arr);
void (*clearna)(void * data, npy_intp n, void * arr);

我们至少添加一个新的便捷宏

#define NPY_NA_SUPPORTED(dtype) ((dtype)->f->isna != NULL)

总体思路是,在任何以前调用数据类型特定函数指针的地方,代码将被修改为:

  1. 检查相关的 NPY_NA_AUTO_... 位是否启用,NA_extends 字段是否非空,以及我们想调用的函数指针是否为空。

  2. 如果满足这些条件,则使用 isna 识别数组中哪些条目是 NA,并进行适当处理。然后,改用 NA_extends 数据类型查找我们 *原本* 要用此数据类型调用的任何函数,并用它来处理非 NA 元素。

更多详细信息,请参见以下章节。

请注意,如果 NA_extends 指向一个参数化数据类型,那么它所指向的数据类型对象必须完全指定。例如,如果它是字符串数据类型,则其 elsize 字段必须非零。

为了处理 NA 信息存储在“真实”数据旁边字段的情况,NA_extends_offset 字段被设置为非零值;它必须指向此数据类型中每个元素内,NA_extends 数据类型数据所在的位置。例如,如果我们存储 10 字节的字符串,并在开头有一个 NA 指示字节,那么我们有:

elsize == 11
NA_extends_offset == 1
NA_extends->elsize == 10

当委托给 NA_extends 数据类型时,我们通过 NA_extends_offset 偏移我们的数据指针(同时保持步幅不变),使其看到预期的类型数据数组(加上一些冗余填充)。据我理解,这基本上与记录数据类型使用的机制相同,因此应该经过了充分测试。

当委托给无法处理“行为异常”源数据(详情请参见 PyArray_ArrFuncs 文档)的函数时,我们需要在委托前检查对齐问题(特别是当 NA_extends_offset 非零时)。如果存在问题,那么我们需要首先“清理”源数据,使用处理未对齐数据的常用机制。(当然,我们通常应该设置数据类型以避免任何对齐问题,但如果有人搞砸了,或者认为减少内存使用比快速内循环更重要,那么我们仍然应该像现在一样优雅地处理。)

NA_valueclearna 字段用于各种类型的类型转换。例如,当从 np.NA 赋值时,NA_value 是要使用的位模式。如果 elsizeNA_extends->elsize 相同,clearna 可以是空操作,但如果不同,它应该清除此数据类型使用的任何辅助 NA 存储,以确保指定的数组元素都不是 NA。

核心数据类型函数#

以下函数定义在 PyArray_ArrFuncs 中。此处描述的特殊行为由数据类型标志中的 NPY_NA_AUTO_ARRFUNCS 位启用,并且仅在给定函数字段 *未* 填充时才启用。

getitem: 调用 isna。如果 isna 返回 true,则返回 np.NA。否则,委托给 NA_extends 数据类型。

setitem: 如果输入对象是 np.NA,则运行 memcpy(self->NA_value, data, arr->dtype->elsize);。否则,调用 clearna,然后委托给 NA_extends 数据类型。

copyswapn, copyswap: FIXME:不确定这些是否需要特殊处理?

compare: FIXME:这应该如何处理 NA?R 的排序函数 *丢弃* NA,这似乎不是一个好选择。

argmax: FIXME:这是用来做什么的?如果它是 np.max 的底层实现,那么它确实需要某种方式来获取 skipna 参数。如果不是,则适当的语义取决于它应该完成什么……

dotfunc: 问题:是否真的保证所有元素的 dtype 都相同?FIXME:与 argmax 相同的问题。

scanfunc: 这个很麻烦。我们可能必须在我们所有的特殊数据类型中明确覆盖它,因为假设我们希望能够选择在文本文件中使用“NA”标记来表示 NA 值,那么在委托之前,我们需要某种方法来检查它是否存在。但是 ungetc 只保证我们可以放回 1 个字符,而我们需要 2 个(如果实际检查“NA ”,可能需要 3 个)。另一种选择是读取到下一个分隔符,检查是否有 NA,如果没有,则委托给 fromstr 而不是 scanfunc,但根据当前 API,每个数据类型原则上可能使用完全不同的规则来定义“下一个分隔符”。所以……有什么想法吗?(FIXME)

fromstr: 简单——检查“NA ”,如果存在则分配 NA_value,否则调用 clearna 并委托。

nonzero: FIXME:这又是用来做什么的?(它似乎与使用类型转换机制转换为布尔值是冗余的。)不过,它可能需要修改以便可以返回 NA……

fill: 使用 isna 检查前两个值中是否有 NA。如果是,则用 NA_value 填充数组的其余部分。否则,调用 clearna 然后委托。

fillwithvalue: 猜测这个可以直接委托吗?

sort, argsort: 这些函数可能应该将 NA 排序到数组的特定位置(前面还是后面——有什么意见吗?)

scalarkind: FIXME:我不知道这是做什么的。

castdict, cancastscalarkindto, cancastto: 请参见下面的类型转换部分。

类型转换#

FIXME:这确实需要 NumPy 类型转换规则专家的关注。但我似乎找不到解释如何查找和决定类型转换循环的文档(例如,如果您从数据类型 A 转换为数据类型 B,会使用哪个数据类型的循环?),因此我无法深入细节。但这些细节很棘手,而且很重要……

但总体的想法是,如果你的数据类型设置了 NPY_NA_AUTO_CAST,那么以下转换将自动允许:

  • 从底层类型到 NA 类型的转换:这通过

  • 通常的 clearna + 可能带步长的复制操作来执行。此外,isna 会被

  • 调用以检查常规值中没有意外地

  • 转换为 NA;如果是,则引发错误。

  • 从 NA 类型到底层类型的转换:原则上允许,但如果 isna 对任何要转换的值返回 true,则再次引发错误。(如果要绕过此问题,请使用 np.view(array_with_NAs, dtype=float)。)

  • NA 类型与不支持 NA 的其他类型之间的转换:如果底层类型允许转换为其他类型,则允许此转换,并通过将到或从底层类型的转换(使用上述规则)与到或从其他类型的转换(使用底层类型的规则)相结合来执行。

  • NA 类型与支持 NA 的其他类型之间的转换:如果其他类型设置了 NPY_NA_AUTO_CAST,那么我们使用上述规则,再加上对一个数组执行 isna 并将其转换为另一个数组中的 NA_value 元素的常规操作。如果只有一个数组设置了 NPY_NA_AUTO_CAST,那么就假定该数据类型知道自己在做什么,我们不进行任何“魔法”操作。(但这其中一点我不太确定是否合理,如我上面所述。)

UFuncs#

所有 ufunc 都获得一个额外的可选关键字参数 skipNA=,其默认值为 False。

如果 skipNA == True,那么 ufunc 机制将 *无条件地* 调用 NPY_NA_SUPPORTED(dtype) 为 true 的任何数据类型的 isna 函数,然后表现得就像 isna 返回 true 的任何值都在 where= 参数中被掩码(请参见 miniNEP 1 了解 where= 的行为)。如果也给出了 where= 参数,那么它的行为就像 isna 值已经从 where= 掩码中进行了 AND 运算,尽管它实际上没有修改掩码。与下面的其他更改不同,这对于定义了 isna 函数的任何数据类型都将 *无条件地* 执行;不会检查 NPY_NA_AUTO_UFUNC 标志。

如果设置了 NPY_NA_AUTO_UFUNC,则 ufunc 循环查找将被修改,以便每当它检查当前数据类型上是否存在循环,并且没有找到时,它也会检查 NA_extends 数据类型上的循环。如果找到了该循环,它将以正常方式使用,例外情况是 (1) 它仅对根据 isna 不是 NA 的值调用,(2) 如果输出数组设置了 NPY_NA_AUTO_UFUNC,则在调用 ufunc 循环之前对其调用 clearna,(3) 在调用 ufunc 循环之前,指针偏移量会根据 NA_extends_offset 进行调整。此外,如果设置了 NPY_NA_AUTO_UFUNC_CHECK,则在评估 ufunc 循环后,我们对 *输出* 数组调用 isna,如果输出中存在输入中没有的任何 NA,则引发错误。(这样做的目的是为了捕获以下情况:例如,我们使用最小负整数表示 NA,然后某人的算术运算溢出意外创建了这样的值。)

FIXME:这里我们应该更详细地说明当存在多个输入数组时(其中一些可能设置了该标志,另一些没有),NPY_NA_AUTO_UFUNC 如何工作。

打印#

FIXME:应该有一种机制,使 NA 值自动表示为 NA,但我不太了解 NumPy 的打印工作原理,所以这部分让其他人来补充。

索引#

a[12] 这样的标量索引是通过 getitem 函数进行的,因此根据上述提案,如果数据类型委托了 getitem,则对 NA 进行标量索引将返回对象 np.NA。(如果它不委托 getitem,当然,它就可以返回任何它想要的东西。)

这似乎是最简单的方法,但另一种替代方案是为标量索引添加一个特殊情况:如果设置了 NPY_NA_AUTO_INDEX 标志,它将对指定元素调用 isna。如果返回 false,它将像往常一样调用 getitem;否则,它将返回一个包含指定元素的 0 维数组。这样做的问题是它会破坏像 if a[i] is np.NA: ... 这样的表达式。(当然,目前对于 NaN 值来说,没有什么比这更方便的了,但是 NaN 值也没有自己的全局单例。)因此,目前我们坚持标量索引只返回 np.NA,但如果有人反对,可以重新讨论。

泛型 NA 支持的 Python API#

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

np.NA 的大多数操作(例如,__add____mul__)都被覆盖以无条件地返回 np.NA

用于诸如 np.asarray([1, 2, 3])np.asarray([1.0, 2.0. 3.0]) 之类的表达式的自动数据类型检测将被扩展,以识别 np.NA 值,并使用它自动切换到内置的启用 NA 的数据类型(具体哪种类型由数组中的其他元素决定)。一个简单的 np.asarray([np.NA]) 将使用启用 NA 的 float64 数据类型(这类似于你从 np.asarray([]) 获得的结果)。请注意,这意味着像 np.log(np.NA) 这样的表达式将起作用:首先 np.NA 将被强制转换为 0 维的 NA 浮点数组,然后对其调用 np.log

Python 级别的数据类型对象新增以下字段

NA_supported
NA_value

NA_supported 是一个布尔值,它简单地暴露了 NPY_NA_SUPPORTED 标志的值;如果此数据类型允许 NA,则应为 true,否则为 false。[FIXME:是否最好只根据 isna 函数的存在来判断?即使一个数据类型决定自己实现所有其他 NA 处理,它仍然必须定义 isna 才能使 skipNA= 正常工作。]

NA_value 是给定数据类型的 0 维数组,其唯一元素包含与该数据类型底层 NA_value 字段相同的位模式。这使得确定此类型 NA 值的默认位模式成为可能(例如,使用 np.view(mydtype.NA_value, dtype=int8))。

至少目前,我们 *不* 在 Python 级别暴露 NA_extendsNA_extends_offset 值;它们被视为实现细节(如果以后需要,暴露它们比不需要时再隐藏它们更容易)。

定义了两个新的 ufunc:np.isNA 返回一个逻辑数组,其中数据类型的 isna 函数返回 true 的位置为 true。np.isnumber 仅为数字数据类型定义,对于所有不是 NA 且 np.isfinite 将返回 True 的元素,它返回 True。

内置 NA 数据类型#

上述描述了数据类型中 NA 支持的通用机制。它足够灵活,可以处理各种情况,但我们也希望定义一些默认可用的、普遍有用的 NA 支持数据类型。

对于每种内置数据类型,我们定义了一个关联的 NA 支持数据类型,如下所示:

  • 浮点数:关联的数据类型使用特定的 NaN 位模式来指示 NA(选择此模式是为了与 R 兼容)

  • 复数:我们按照 R 的做法(FIXME:查找一下——可能是两个 NA 浮点数?)

  • 有符号整数:使用最小负有符号值作为 NA(选择此模式是为了与 R 兼容)

  • 无符号整数:使用最大正值作为 NA(无法与 R 兼容)。

  • 字符串:第一个字节(或在 Unicode 字符串的情况下,前 4 个字节)用作指示 NA 的标志,其余数据为实际字符串内容。(无法与 R 兼容)

  • 对象:两种选择(FIXME):要么我们不包含带 NA 的版本,要么我们使用 np.NA 作为 NA 位模式。

  • 布尔值:我们按照 R 的做法(FIXME:查找一下——0 == FALSE,1 == TRUE,2 == NA?)

这些数据类型都可以使用上述机制轻松定义,并且由自动类型推断机制(例如 np.asarray([True, np.NA, False]) 等)自动使用。

它们也可以通过一个新函数 np.withNA 访问,该函数接受一个常规数据类型(或可以强制转换为数据类型的对象,如 'float')并返回上述数据类型之一。理想情况下,withNA 还应该接受一些可选参数,让您描述哪些值要算作 NA 等,但这部分我将留待未来的草案(FIXME)。

FIXME:如果 d 是上述数据类型之一,那么 d.type 应该返回什么?

该 NEP 还包含一个关于描述 NA 数据类型的复杂领域特定语言的提案。我不确定这个想法有多好。(我个人偏向于反对使用字符串作为数据结构,并且认为现有的字符串已经足够令人困惑了——此外,显然 NEP 版本的 NumPy 在打印数据类型时使用像 'f8' 这样的字符串,而我的 NumPy 使用像 'float64' 这样的对象名,所以我不知道那儿发生了什么。至少对我来说,withNA(float64, arg1=value1) 似乎比“NA[f8,value1]”更令人愉悦地打印数据类型。)但如果人们想要它,那也行。

类型层次结构#

FIXME:我们应该如何对 NA 数据类型进行子类型检查等操作?issubdtype(withNA(float), float) 返回什么?那么 issubdtype(withNA(float), np.floating) 呢?

序列化#