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

作者:

Nathaniel J. Smith <njs@pobox.com>

状态:

延迟

类型:

标准跟踪

创建:

2011-07-08

摘要#

上下文:此 NEP 是作为 NEP 12 的额外替代方案编写的(NEP 24 是另一种替代方案),在撰写本文时,其实现已合并到 NumPy 主分支中。

为了在整个缺失值/掩码数组/……讨论中取得更多进展,似乎有必要对我们 *可以* 同意的内容进行更技术性的讨论。这是第二个,它试图确定如何使用特殊数据类型实现 NA 的细节。

基本原理#

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

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

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

对于浮点运算,NA 和 NaN 的行为(几乎)相同。但它们代表不同的东西——NaN 是无效的计算,例如 0/0,NA 是不可用的值——区分这些东西是有用的,因为在某些情况下应该不同地对待它们。(例如,插补过程应该用插补值替换 NA,但可能应该保留 NaN。)无论如何,我们不能对整数、字符串或布尔值使用 NaN,因此我们仍然需要 NA,一旦我们对所有这些类型都支持 NA,我们也可能为了保持一致性而支持浮点数。

总体策略#

NumPy 已经有一个通用的机制来定义新的数据类型并将其插入,以便它们得到 ndarray、转换机制、ufunc 等的支持。原则上,我们可以仅使用这些现有接口来实现 NA-dtype。但我们不想这样做,因为从头开始定义所有这些新的 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 扩展#

The 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 */

The 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 字段是否为非 NULL,以及我们想要调用的函数指针是否为 NULL。

  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(同时保持步长相同),以便它看到期望类型的数据数组(加上一些多余的填充)。这基本上与记录数据类型使用的机制相同,IIUC,因此它应该经过充分测试。

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

The NA_valueclearna 字段用于各种类型的转换。 NA_value 是一个位模式,用于例如从 np.NA 分配。如果 elsizeNA_extends->elsize 相同,则 clearna 可以是空操作,但如果它们不同,则它应该清除此数据类型使用的任何辅助 NA 存储,以便没有指定数组元素为 NA。

核心数据类型函数#

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

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

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

copyswapncopyswap:待办事项:不确定是否需要对这些进行任何特殊处理?

compare:待办事项:此函数如何处理 NA?R 的排序函数 *丢弃* NA,这似乎不是一个好的选择。

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

dotfunc:问题:是否真正保证所有内容都具有相同的数据类型?待办事项:与 argmax 相同的问题。

scanfunc:此函数很丑。我们可能必须在所有特殊数据类型中显式覆盖它,因为假设我们想要选择将令牌“NA”表示文本文件中的 NA 值,我们需要某种方式来检查它是否存在,然后再委托。但 ungetc 只能保证我们放回 1 个字符,而我们需要 2 个(如果我们实际上检查“NA ”,则可能需要 3 个)。另一种选择是读取到下一个分隔符,检查我们是否有一个 NA,如果不存在,则委托给 fromstr 而不是 scanfunc,但根据当前的 API,原则上每个数据类型都可能使用完全不同的规则来定义“下一个分隔符”。所以……有什么想法吗?(待办事项)

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

nonzero:待办事项:同样,此函数用于什么?(它似乎与使用强制机制强制转换为布尔值冗余。)可能它需要修改,以便它可以返回 NA……

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

fillwithvalue:猜想这可以简单地委托?

sortargsort:这些函数应该可能安排将 NA 排序到数组中的特定位置(要么是开头,要么是结尾 - 有什么意见吗?)

scalarkind:待办事项:我不知道这做了什么。

castdictcancastscalarkindtocancastto:请参阅下面关于强制转换的部分。

强制转换#

待办事项:这确实需要 NumPy 强制转换规则专家的关注。但我似乎找不到解释如何查找和决定强制转换循环的文档(例如,如果你从数据类型 A 强制转换为数据类型 B,则使用哪个数据类型的循环?),因此我无法详细说明。但这些细节很棘手,它们很重要……

但总体思路是,如果你有一个设置了 NPY_NA_AUTO_CAST 的数据类型,则以下转换将自动允许

  • 从底层类型强制转换为 NA 类型:这是通过

  • 通常的 clearna + 可能带步长的复制操作完成。另外,isna

  • 调用来检查是否有任何常规值意外

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

  • 从 NA 类型强制转换为底层类型:原则上允许,但如果 isna 对任何要转换的值返回真值,则也会引发错误。(如果你想绕过此限制,请使用 np.view(array_with_NAs, dtype=float)。)

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

  • 在 NA 类型和支持 NA 的其他类型之间强制转换:如果另一个类型设置了 NPY_NA_AUTO_CAST,则我们使用上述规则以及在将一个数组转换为另一个数组的 NA_value 元素时对 isna 的通常操作。如果只有一个数组设置了 NPY_NA_AUTO_CAST,则假设该数据类型知道它在做什么,我们不会执行任何魔法。(但这正是我上面提到的我不确定是否有意义的事情之一。)

通用函数#

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

如果 skipNA == True,则通用函数机制 *无条件* 对任何 NPY_NA_SUPPORTED(dtype) 为真的数据类型调用 isna,然后就像 where= 参数中屏蔽了 isna 返回真值的任何值一样(请参阅 miniNEP 1 以了解 where= 的行为)。如果还提供了 where= 参数,则它就像 isna 值已从 where= 掩码中 AND 运算一样,尽管它实际上不会修改掩码。与下面的其他更改不同,这将 *无条件* 对任何定义了 isna 函数的数据类型执行;不会检查 NPY_NA_AUTO_UFUNC 标志。

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

待办事项:我们应该在此详细介绍当存在多个输入数组(其中一些可能设置了标志,而另一些则没有)时 NPY_NA_AUTO_UFUNC 的工作原理。

打印#

待办事项:应该有一些机制可以自动将 NA 值表示为 NA,但我并不真正了解 NumPy 打印的工作原理,所以我会让其他人填写此部分。

索引#

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

这似乎是最简单的方法,但另一种方法是在标量索引中添加一个特殊情况,如果设置了 NPY_NA_AUTO_INDEX 标志,则它将在指定元素上调用 isna。如果这返回假值,它将像往常一样调用 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-float 数组,然后对它调用 np.log

Python 级别的数组类型对象获得了以下新字段

NA_supported
NA_value

NA_supported 是一个布尔值,它只是公开 NPY_NA_SUPPORTED 标志的值;如果该数据类型允许 NA,则应该为真,否则为假。[待办事项:是否最好仅仅根据 isna 函数的存在来判断这一点?即使数据类型决定自己实现所有其他 NA 处理,它仍然必须定义 isna 才能使 skipNA= 正确工作。]

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

我们*不*公开 NA_extendsNA_extends_offset 值在 Python 级别,至少目前是这样;它们被认为是实现细节(如果它们以后需要,更容易公开它们,如果不需要,则更容易隐藏它们)。

定义了两个新的通用函数:np.isNA 返回一个逻辑数组,在数据类型的 isna 函数返回真值的任何地方都有真值。 np.isnumber 仅针对数字数据类型定义,对于所有不为 NA 的元素,以及对于 np.isfinite 将返回真值的元素返回真值。

内置 NA 数据类型#

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

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

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

  • 复数:我们执行 R 做的任何操作(待办事项:查找此操作 - 可能两个 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) 怎么样?

序列化#