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

作者:

Nathaniel J. Smith <njs@pobox.com>

状态:

已延迟

类型:

标准轨道

创建:

2011-07-08

摘要#

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

为了在整个缺失值/掩码数组/… 的争论上取得更多进展,对我们 *可以* 达成一致的部分进行更技术性的讨论似乎很有用。这是第二个,它试图确定如何使用特殊 dtype 实现 NA 值的细节。

基本原理#

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

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

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

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

总体策略#

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

一些示例用例
  1. 我们想定义一个 dtype,它的行为与 int32 完全相同,只是最负的值被视为 NA。

  2. 我们想定义一种参数化的dtype来表示分类数据,而用于NA的位模式取决于定义的类别数量,因此我们的代码需要主动处理它,而不是简单地依赖标准机制。

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

  4. 我们想定义一个dtype,它允许多种不同类型的NA数据,这些数据打印方式不同,并且可以通过我们定义的新ufunc(称为is_na_of_type(...))来区分,但在大多数操作中仍然可以利用通用的NA机制。

dtype 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)

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

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

  2. 如果满足这些条件,则使用isna来识别数组中哪些条目是NA,并适当地处理它们。然后使用此dtype在NA_extends dtype上查找我们本来要调用的函数,并用它来处理非NA元素。

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

请注意,如果NA_extends指向参数化dtype,则其指向的dtype对象必须完全指定。例如,如果它是一个字符串dtype,则它必须具有非零elsize字段。

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

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

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

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

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

核心dtype函数#

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

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

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

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

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

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

dotfunc:问题:是否实际上保证所有内容都具有相同的dtype?待定:与argmax相同的问题。

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

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

nonzero:待定:同样,这是用于什么?(它似乎与使用强制转换机制强制转换为bool是冗余的。)可能需要修改它,以便它可以返回NA……

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

fillwithvalue:猜想这可以只委托?

sortargsort:这些可能应该安排将NA排序到数组中的特定位置(前面或后面——有什么意见?)

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

castdictcancastscalarkindtocancastto:请参见下面的强制转换部分。

强制转换#

待定:这确实需要NumPy强制转换规则专家的关注。但我似乎找不到解释如何查找和确定强制转换循环的文档(例如,如果从dtype A强制转换为dtype B,则使用哪个dtype的循环?),所以我无法详细说明。但这些细节很棘手,而且很重要……

但总的思路是,如果你的dtype设置了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,则假定该dtype知道自己在做什么,我们不做任何魔术。(但这正是我不确定是否有意义的事情之一,正如我上面的警告一样。)

Ufuncs#

所有ufunc都增加了一个额外的可选关键字参数skipNA=,默认为False。

如果skipNA == True,则ufunc机制将*无条件地*为任何NPY_NA_SUPPORTED(dtype)为真的dtype调用isna,然后就像where=参数中已屏蔽掉任何isna返回True的值一样(关于where=的行为,参见miniNEP 1)。如果也给出了where=参数,则它的行为就像isna值已从where=掩码中进行AND运算一样,尽管它实际上并没有修改掩码。与下面的其他更改不同,这对于任何已定义isna函数的dtype都是*无条件地*执行的;不会检查NPY_NA_AUTO_UFUNC标志。

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

待办事项:我们应该更详细地说明当有多个输入数组(其中一些可能设置了标志而另一些没有)时,NPY_NA_AUTO_UFUNC是如何工作的。

打印#

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

索引#

a[12]这样的标量索引通过getitem函数进行,因此根据上述建议,如果dtype委托了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])这样的表达式的自动dtype检测将扩展到识别np.NA值,并使用它自动切换到启用NA的内置dtype(哪个dtype由数组中的其他元素确定)。简单的np.asarray([np.NA])将使用启用NA的float64 dtype(这类似于从np.asarray([])获得的结果)。请注意,这意味着像np.log(np.NA)这样的表达式将起作用:首先np.NA将被强制转换为0维NA浮点数组,然后对该数组调用np.log

Python级dtype对象获得了以下新字段

NA_supported
NA_value

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

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

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

定义了两个新的ufunc:np.isNA返回一个逻辑数组,其中dtype的isna函数返回true的地方为真值。np.isnumber仅针对数值dtype定义,对于所有不是NA的元素以及np.isfinite将返回True的元素返回True。

内置NA dtype#

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

对于每个内置dtype,我们定义一个相关的支持NA的dtype,如下所示

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

  • 复数:我们执行R执行的操作(待办事项:查找此项——可能是两个NA浮点数?)

  • 有符号整数:最小的负值用作NA(为与R兼容而选择)

  • 无符号整数:最大的值用作NA(不可能与R兼容)。

  • 字符串:第一个字节(或者,对于unicode字符串,是前4个字节)用作标志来指示NA,其余数据给出实际的字符串。(不可能与R兼容)

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

  • 布尔值:我们执行R执行的操作(待办事项:查找此项——0 == FALSE,1 == TRUE,2 == NA?)

每个dtype都使用上述机制进行简单的定义,并且是自动类型推断机制自动使用的(对于np.asarray([True, np.NA, False])等)。

它们也可以通过一个新的函数np.withNA访问,该函数接受一个常规dtype(或可以强制转换为dtype的对象,如“float”)并返回上述dtype之一。理想情况下,withNA还应该接受一些可选参数,让您描述您想要计算为NA的值等,但我将把它留给未来的草稿(待办事项)。

待办事项:如果d是上述dtype之一,那么d.type应该返回什么?

NEP还包含一个关于描述NA dtype的相当复杂的特定领域语言的提案。我不确定这是一个多么好的主意。(我偏向于不使用字符串作为数据结构,并且发现现有的字符串已经足够混乱了——此外,NumPy的NEP版本在打印dtype时使用像“f8”这样的字符串,而我的NumPy使用像“float64”这样的对象名称,所以我不知道那里发生了什么。withNA(float64, arg1=value1)在我看来,至少比“NA[f8,value1]”更是一种愉快的打印dtype的方式。)但是如果人们想要它,那就很酷。

类型层次结构#

待办事项:我们应该如何对NA dtype进行子类型检查等?issubdtype(withNA(float), float)返回什么?issubdtype(withNA(float), np.floating)呢?

序列化#