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 处理的代码,数据类型可以根据需要选择性地使用或不使用这些代码。
- 一些用例示例
我们希望定义一种数据类型,其行为与 int32 完全相同,只是将最小负值视为 NA。
我们希望定义一个参数化的数据类型来表示分类数据,并且用于 NA 的位模式取决于定义的类别数量,因此我们的代码需要主动处理它,而不是简单地委托给标准机制。
我们希望定义一种数据类型,其行为类似于长度为 10 的字符串并支持 NA。由于我们的字符串可能包含任意二进制值,我们实际上希望为其分配 11 字节,其中第一个字节作为标志指示此字符串是否为 NA,其余字节包含字符串内容。
我们希望定义一种数据类型,允许多种不同类型的 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)
总体思路是,在任何以前调用数据类型特定函数指针的地方,代码将被修改为:
检查相关的
NPY_NA_AUTO_...
位是否启用,NA_extends 字段是否非空,以及我们想调用的函数指针是否为空。如果满足这些条件,则使用
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_value
和 clearna
字段用于各种类型的类型转换。例如,当从 np.NA 赋值时,NA_value
是要使用的位模式。如果 elsize
和 NA_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_extends
和 NA_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)
呢?
序列化#
版权#
本文档已进入公共领域。