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 处理的代码,数据类型可以选择性地使用或不使用这些代码。
- 一些示例用例
我们希望定义一个数据类型,它的行为与 int32 完全相同,除了最小的负值被视为 NA。
我们希望定义一个参数化的数据类型来表示 分类数据,用于 NA 的位模式取决于定义的类别数,因此我们的代码需要积极参与处理它,而不是简单地委托给标准机制。
我们希望定义一个数据类型,它的行为类似于长度为 10 的字符串并支持 NA。由于我们的字符串可能包含任意二进制值,因此我们希望实际为此分配 11 个字节,第一个字节是指示该字符串是否为 NA 的标志,其余部分包含字符串内容。
我们希望定义一个数据类型,它允许多种不同类型的 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)
一般思路是,在我们以前调用数据类型特定函数指针的地方,代码将被修改为:
检查相关
NPY_NA_AUTO_...
位是否已启用,NA_extends 字段是否为非 NULL,以及我们想要调用的函数指针是否为 NULL。如果满足这些条件,则使用
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_value
和 clearna
字段用于各种类型的转换。 NA_value
是一个位模式,用于例如从 np.NA 分配。如果 elsize
和 NA_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
数据类型。
copyswapn
、copyswap
:待办事项:不确定是否需要对这些进行任何特殊处理?
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
:猜想这可以简单地委托?
sort
、argsort
:这些函数应该可能安排将 NA 排序到数组中的特定位置(要么是开头,要么是结尾 - 有什么意见吗?)
scalarkind
:待办事项:我不知道这做了什么。
castdict
、cancastscalarkindto
、cancastto
:请参阅下面关于强制转换的部分。
强制转换#
待办事项:这确实需要 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_extends
和 NA_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)
怎么样?
序列化#
版权#
此文档已进入公有领域。