NEP 40 — NumPy 中的传统数据类型实现#

标题:

NumPy 中的传统数据类型实现

作者:

Sebastian Berg

状态:

最终

类型:

信息

创建:

2019-07-17

注意

此 NEP 是一个系列中的第一个

  • NEP 40(本文档)解释了 NumPy dtype 实现的缺点。

  • NEP 41概述了我们提出的替换方案。

  • NEP 42描述了新设计的与数据类型相关的 API。

  • NEP 43描述了新设计的通用函数 API。

摘要#

作为进一步 NumPy 增强提案 41、42 和 43 的准备。此 NEP 详细说明了截至 NumPy 1.18 的 NumPy 数据类型的当前状态。它描述了一些促使其他提案的 技术方面和概念。对于大多数读者来说,应该首先阅读 NEP 41,并将本文档仅用作参考或获取更多详细信息。

详细说明#

本节描述了一些核心概念,并简要概述了当前 dtype 的实现以及讨论。在许多情况下,子节将大致分为两部分,首先描述当前实现,然后接着是“问题和讨论”部分。

参数化数据类型#

一些数据类型本质上是参数化的。所有 np.flexible 标量类型都附加到参数化数据类型(字符串、字节和空值)。类 np.flexible 用于标量,是可变长度数据类型(字符串、字节和空值)的超类。这种区别也通过 C 宏 PyDataType_ISFLEXIBLEPyTypeNum_ISFLEXIBLE 暴露出来。这种灵活性推广到可以在数组中表示的值集。例如,"S8" 可以表示比 "S4" 更长的字符串。因此,参数化字符串数据类型也将数组中的值限制为所有可以由字符串标量表示的值的子集(或子类型)。

基本数值数据类型不灵活(不继承自 np.flexible)。float64float32 等确实具有字节顺序,但描述的值不受其影响,并且始终可以将它们转换为本地规范表示,而不会有任何信息丢失。

灵活性概念可以推广到参数化数据类型。例如,私有 PyArray_AdaptFlexibleDType 函数也接受朴素的日期时间 dtype 作为输入以找到正确的时间单位。因此,日期时间 dtype 不是在存储大小上参数化的,而是在存储值所代表的内容上参数化的。当前 np.can_cast("datetime64[s]", "datetime64[ms]", casting="safe") 返回 true,尽管不清楚这是否是想要的或可以推广到可能的未来数据类型,例如物理单位。

因此,我们拥有具有以下属性的数据类型(主要是字符串):

  1. 强制转换并非总是安全的(np.can_cast("S8", "S4")

  2. 数组强制应该能够发现确切的 dtype,例如对于 np.array(["str1", 12.34], dtype="S"),NumPy 将发现结果 dtype 为 "S5"。(如果省略了 dtype 参数,则当前行为未定义 [gh-15327]。)与 dtype="S" 类似的形式是 dtype="datetime64",它可以发现单位:np.array(["2017-02"], dtype="datetime64")

这一概念突出表明,一些数据类型比基本数值数据类型更复杂,这在通用函数的复杂输出类型发现中很明显。

基于值的强制转换#

强制转换通常在两种类型之间定义:当第二种类型可以在不损失信息的情况下表示第一种类型的全部值时,则认为一种类型可以安全地强制转换为第二种类型。NumPy 可能会检查实际值以确定强制转换是否安全。

这在例如以下表达式中很有用:

arr = np.array([1, 2, 3], dtype="int8")
result = arr + 5
assert result.dtype == np.dtype("int8")
# If the value is larger, the result will change however:
result = arr + 500
assert result.dtype == np.dtype("int16")

在这个表达式中,python 值(最初没有数据类型)被表示为 int8int16(最小的可能数据类型)。

NumPy 目前甚至对 NumPy 标量和零维数组执行此操作,因此在上述表达式中用 np.int64(5)np.array(5, dtype="int64") 替换 5 将导致相同的结果,从而忽略了现有数据类型。相同的逻辑也适用于浮点标量,允许它们损失精度。当两个输入都是标量时,该行为不被使用,因此 5 + np.int8(5) 返回默认整数大小(32 或 64 位),而不是 np.int8

虽然该行为是在强制转换方面定义的,并通过 np.result_type 暴露出来,但它对于通用函数(如上述示例中的 np.add)尤为重要。通用函数当前依赖于安全强制转换语义来决定应该使用哪个循环,从而决定输出数据类型。

问题和讨论#

似乎有一种共识,即当前方法对于具有数据类型的 value 不适用,但对于第一个示例中的纯 python 整数或浮点数可能很有用。但是,任何数据类型系统和通用函数调度的更改都必须首先完全支持当前行为。主要困难在于,例如值 156 可以由 np.uint8np.int16 表示。结果取决于转换上下文中“最小”表示(对于 ufunc,上下文可能取决于循环顺序)。

对象数据类型#

对象数据类型当前用作任何无法以其他方式表示的值的通用后备。但是,由于没有明确定义的类型,它有一些问题,例如当数组用 Python 序列填充时

>>> l = [1, [2]]
>>> np.array(l, dtype=np.object_)
array([1, list([2])], dtype=object)  # a 1d array

>>> a = np.empty((), dtype=np.object_)
>>> a[...] = l
ValueError: assignment to 0-d array  # ???
>>> a[()] = l
>>> a
array(list([1, [2]]), dtype=object)

如果没有明确定义的类型,诸如 isnan()conjugate() 之类的函数不一定起作用,但对于 decimal.Decimal 可能会起作用。为了改善这种情况,似乎应该让创建表示特定 Python 数据类型的 object dtype变得容易,并将其对象以指向 Python PyObject 的指针的形式存储在数组中。与大多数数据类型不同,Python 对象需要垃圾回收。这意味着必须定义用于处理引用和访问所有对象的附加方法。在实践中,对于大多数用例,限制此类数据类型的创建就足够了,以便与 Python C 级引用相关的所有功能都对 NumPy 私有。

创建与内置 Python 对象匹配的 NumPy 数据类型还会带来一些需要更多思考和讨论的问题。这些问题不需要立即解决。

  • NumPy 目前在某些情况下即使对于数组输入也会返回标量,在大多数情况下,这可以无缝地工作。然而,这仅仅是因为 NumPy 标量与 NumPy 数组的行为非常相似,而这是普通 Python 对象所不具备的特性。

  • 无缝集成可能需要 np.array(scalar) 自动找到正确的 DType,因为某些操作(例如索引)会返回标量而不是 0D 数组。如果多个用户独立地决定实现例如 decimal.Decimal 的 DType,就会出现问题。

当前 dtype 实现#

目前 np.dtype 是一个 Python 类,其实例是 np.dtype(">float64") 等实例。为了设置这些实例的实际行为,全局存储一个原型实例,并根据 dtype.typenum 进行查找。单例在可能的情况下使用。在需要时,会进行复制和修改,例如更改字节序。

参数化数据类型(字符串、void、datetime 和 timedelta)必须存储额外的信息,例如字符串长度、字段或 datetime 单位 - 这些类型的新的实例会创建,而不是依赖单例。NumPy 中的所有当前数据类型进一步支持在创建期间设置元数据字段,该字段可以设置为任意字典值,但实际上很少使用(最近一个突出使用者的例子是 h5py)。

许多数据类型特定的函数是在一个名为 PyArray_ArrFuncs 的 C 结构体中定义的,该结构体是每个 dtype 实例的一部分,与 Python 的 PyNumberMethods 类似。对于用户定义的数据类型,该结构体对用户公开,这使得 ABI 兼容的更改变得不可能。该结构体保存重要的信息,例如如何复制或转换,并为函数指针提供空间,例如比较元素、转换为 bool 或排序。由于其中一些函数是向量化操作,对多个元素进行操作,因此它们适合 ufunc 模型,将来不需要在数据类型上定义它们。例如,np.clip 函数以前使用 PyArray_ArrFuncs 实现,现在作为 ufunc 实现。

讨论和问题#

当前函数在 dtype 上的实现的另一个问题是,与方法不同,它们在调用时不会传递 dtype 的实例。相反,在许多情况下,被操作的数组会传递进来,并且通常只用于再次提取数据类型。未来的 API 可能会停止传递完整的数组对象。由于需要回退到旧的定义以实现向后兼容性,因此可能无法使用数组对象。但是,传递一个主要定义了数据类型的“伪”数组可能是一个足够的解决方法(参见向后兼容性;有时也可能需要对齐信息)。

尽管在 NumPy 本身之外没有广泛使用,但目前 PyArray_Descr 是一个公共结构体。对于存储在 f 字段中的 PyArray_ArrFuncs 结构体来说,尤其如此。由于兼容性问题,它们可能需要在很长一段时间内保持支持,并有可能被分派到更新 API 的函数所取代。

然而,从长远来看,访问这些结构体可能必须被弃用。

NumPy 标量和类型层次结构#

作为上述数据类型实现的旁注:与数据类型不同,NumPy 标量目前确实提供了一个类型层次结构,它由抽象类型组成,例如 np.inexact(见下图)。事实上,NumPy 中的一些控制流目前使用 issubclass(a.dtype.type, np.inexact)

_images/nep-0040_dtype-hierarchy.png

图:从参考文档复制的 NumPy 标量类型的层次结构。一些别名,例如 np.intp 被排除在外。Datetime 和 timedelta 未显示。#

NumPy 标量试图模仿具有固定数据类型的零维数组。对于数值(和 Unicode)数据类型,它们进一步限制为本机字节序。

转换的当前实现#

数据类型需要支持的主要功能之一是使用 arr.astype(new_dtype, casting="unsafe") 在彼此之间进行转换,或者在执行不同类型(例如添加整数和浮点数)的 ufunc 时进行转换。

转换表决定是否可以从一个特定类型转换到另一个特定类型。然而,通用转换规则无法处理参数化数据类型,例如字符串。参数化数据类型的逻辑主要在 PyArray_CanCastTo 中定义,目前无法为用户定义的数据类型自定义。

实际转换有两个不同的部分

  1. copyswap/copyswapn 为每个 dtype 定义,可以处理非本机字节序的字节交换以及未对齐的内存。

  2. 通用转换代码由 C 函数提供,这些函数知道如何将对齐且连续的内存从一个 dtype 转换为另一个 dtype(都采用本机字节序)。这些 C 级函数可以注册以将对齐且连续的内存从一个 dtype 转换为另一个 dtype。该函数可能提供两个数组(尽管参数有时对于标量来说是 NULL)。NumPy 将确保这些函数接收本机字节序输入。当前实现将函数存储在要转换的 dtype 的 C 数组中,或者在转换到用户定义的 dtype 时存储在字典中。

通常,NumPy 将因此将转换执行为三个函数 in_copyswapn -> castfunc -> out_copyswapn 的链,在这些步骤之间使用(小的)缓冲区。

上述多个函数被包装成一个单一函数(带有元数据),该函数处理转换,例如在 ufunc 使用的缓冲迭代期间使用。这是始终用于用户定义数据类型的机制。对于在 NumPy 本身中定义的大多数 dtype,使用更专门的代码来查找执行实际转换的函数(由私有的 PyArray_GetDTypeTransferFunction 定义)。该机制取代了上述大多数机制,并且在输入在内存中不连续时提供了更快的转换。但是,它无法由用户定义的数据类型扩展。

与转换相关的是,我们目前有一个 PyArray_EquivTypes 函数,它指示视图就足够了(因此不需要转换)。该函数在多个地方使用,并且可能应该成为重新设计的转换 API 的一部分。

通用函数中的 DType 处理#

通用函数被实现为 numpy.UFunc 类的实例,它包含一个有序的数据类型特定(基于 dtype 类型代码字符,而不是数据类型实例)实现列表,每个实现都有一个签名和一个函数指针。这个实现列表可以用 ufunc.types 查看,其中列出了所有实现及其 C 样式类型代码签名。例如

>>> np.add.types
[...,
 'll->l',
 ...,
 'dd->d',
 ...]

每个签名都与一个在 C 中定义的单一内部循环函数关联,该函数执行实际的计算,并且可能会被多次调用。

查找正确内部循环函数的主要步骤是调用 PyUFunc_TypeResolutionFunc,该函数从提供的输入数组中检索输入 dtype,并确定要执行的完整类型签名(包括输出 dtype)。

默认情况下,TypeResolver 通过按顺序搜索 ufunc.types 中列出的所有实现来实现,并在所有输入都可以安全地转换为匹配签名时停止。这意味着如果添加了长 (l) 和双 (d) 数组,NumPy 将发现 'dd->d' 定义有效(长可以安全地转换为双),并使用它。

在某些情况下,这是不可取的。例如,np.isnat 通用函数有一个 TypeResolver,它拒绝整数输入,而不是允许它们转换为浮点数。原则上,下游项目目前可以使用自己的非默认 TypeResolver,因为执行此操作所需的相应 C 结构体是公开的。已知执行此操作的唯一项目是 Astropy,该项目愿意切换到新的 API,如果 NumPy 删除了替换 TypeResolver 的可能性。

对于用户定义的数据类型,分派逻辑类似,尽管是单独实现的并且有限制(参见下面的讨论)。

问题和讨论#

目前,只有当任何输入(或输出)具有用户数据类型时,才能找到/解析用户定义的函数,因为它使用OO->O签名。例如,假设已经实现了一个用于实现fraction_divide(int, int) -> Fraction的ufunc循环,则调用fraction_divide(4, 5)(没有指定输出dtype)将失败,因为包含用户数据类型Fraction(作为输出)的循环只有在任何输入已经是Fraction时才能找到。 可以使fraction_divide(4, 5, dtype=Fraction)起作用,但它很不方便。

通常,调度是通过查找第一个匹配的循环来完成的。匹配定义为:所有输入(以及可能的输出)都可以安全地转换为签名类型字符(另请参见当前实现部分)。但是,在某些情况下,安全转换存在问题,因此明确不允许。例如,np.isnat函数目前仅针对日期时间和时间间隔定义,即使整数被定义为可以安全地转换为时间间隔。如果不是这样,调用np.isnat(np.array("NaT", "timedelta64").astype("int64"))目前将返回真值,即使整数输入数组没有“非时间”的概念。如果一个通用函数,例如 scipy.special 中的大多数函数,仅针对 float32float64 定义,它目前将自动将 float16 静默转换为 float32(类似于任何整数输入)。这确保了成功执行,但可能会导致在向 ufunc 添加对新数据类型的支持时输出 dtype 发生更改。当添加 float16 循环时,输出数据类型目前将从 float32 更改为 float16 而不发出警告。

通常,注册循环的顺序很重要。但是,这只有在第一次定义 ufunc 时添加所有循环时才可靠。在导入新的用户数据类型时添加的额外循环不能对导入发生的顺序敏感。

有两种主要方法可以更好地定义用户定义类型的类型解析

  1. 允许用户 dtype 直接影响循环选择。例如,它们可以提供一个函数,当没有可用的精确匹配循环时,该函数返回/选择一个循环。

  2. 定义所有实现/循环的总顺序,可能基于“安全转换”语义或类似的语义。

虽然选项 2 可能更容易推理,但还需要看它是否足以满足所有(或大多数)用例。

UFuncs 中参数输出 DTypes 的调整#

参数 dtype 所需的第二个步骤目前在 TypeResolver 中执行:日期时间和时间间隔数据类型必须确定操作和输出数组的正确参数。此步骤还需要再次检查所有转换是否可以安全地执行,默认情况下意味着它们是“相同类型”的转换。

问题和讨论#

修复正确的输出 dtype 目前是类型解析的一部分。但是,它是一个不同的步骤,应该在实际的类型/循环解析发生后作为这样的步骤处理。

因此,此步骤可能会从上面的调度步骤移动到下面描述的实现特定代码。

UFunc 的 DType 特定实现#

一旦找到正确的实现/循环,UFuncs 目前会调用一个用 C 编写的单个内部循环函数。这可能被多次调用以完成完整的计算,并且它几乎没有关于当前上下文的任何信息。它还具有 void 返回值。

问题和讨论#

参数数据类型可能需要将附加信息传递给内部循环函数,以确定如何解释数据。这就是目前不存在针对 string dtype 的通用函数的原因(尽管在 NumPy 本身中在技术上是可能的)。请注意,目前可以传入输入数组对象(当不需要转换时,这些对象反过来会保存数据类型)。但是,不应该需要完整的数组信息,并且目前在进行任何转换之前传入数组。此功能在 NumPy 中未使用,并且没有已知的用户存在。

另一个问题是内部循环函数中的错误报告。目前有两种方法可以做到这一点

  1. 通过设置 Python 异常

  2. 使用 CPU 浮点错误标志。

这两者都在返回给用户之前进行检查。但是,许多整数函数目前不能设置这两个错误,因此检查浮点错误标志是多余的开销。另一方面,没有办法停止迭代或传递不使用浮点标志或需要持有 Python 全局解释器锁 (GIL) 的错误信息。

似乎有必要为内部循环函数的作者提供更多控制。这意味着允许用户更轻松地传入和传出内部循环函数的信息,同时提供输入数组对象。最有可能涉及

  • 允许在第一次和最后一次内部循环调用之前执行附加代码。

  • 从内部循环返回整数值以允许尽早停止迭代并可能传播错误信息。

  • 可能,允许专门的内部循环选择。例如,目前 matmul 和许多约简将为某些输入执行优化代码。允许预先选择此类优化循环可能是有意义的。允许这样做也有助于将转换(大量使用此功能)和 ufunc 实现更紧密地结合在一起。

围绕内部循环函数的问题在 github 问题 gh-12518 中进行了详细讨论。

约简使用“标识”值。这目前针对每个 ufunc 定义一次,而不管 ufunc dtype 签名如何。例如,0 用于 sum,或者 math.inf 用于 min。这对于数值数据类型很有效,但并不总是适用于其他 dtype。通常,应该能够为 ufunc 约简提供特定于 dtype 的标识。

数组强制转换期间的数据类型发现#

当调用 np.array(...) 将通用 Python 对象强制转换为 NumPy 数组时,需要检查所有对象以找到正确的 dtype。对 np.array() 的输入可能是包含最终元素为通用 Python 对象的嵌套 Python 序列。NumPy 必须解包所有嵌套序列,然后检查元素。最终数据类型是通过遍历最终将出现在数组中的所有元素来找到的,并且

  1. 发现单个元素的 dtype

    • 使用 element.dtype 从数组(或类似数组)或 NumPy 标量中获取

    • 对于已知的 Python 类型,使用 isinstance(..., float)(请注意,这些规则意味着子类当前有效)。

    • 针对 void 数据类型强制转换元组的特殊规则。

  2. 使用 np.promote_types 使用下一个元素的 dtype 提升当前 dtype。

  3. 如果找到字符串,则整个过程会重新开始(另请参见 [gh-15327]),方式类似于给出 dtype="S"(见下文)。

如果给出 dtype=...,则将使用此 dtype 而不进行修改,除非它是一个非特定的参数化 dtype 实例,这意味着“S0”、“V0”、“U0”、“datetime64”和“timdelta64”。因此,这些是灵活的数据类型,长度不为 0(被认为是无尺寸的)——以及没有附加单元(“通用单元”)的日期时间或时间间隔。

在未来的 DType 类层次结构中,这些可能由类而不是特殊实例表示,因为这些特殊实例通常不应该附加到数组。

如果提供此类参数化 dtype 实例,例如使用 dtype="S",则会调用 PyArray_AdaptFlexibleDType,并且会使用特定于 DType 的逻辑有效地检查所有值。也就是说

  • 字符串将使用 str(element) 来查找大多数元素的长度

  • Datetime64 能够从字符串强制转换并猜测正确的单位。

讨论和问题#

在正常发现过程中,isinstance 似乎更应该进行严格的 type(element) is desired_type 检查。此外,当前的 AdaptFlexibleDType 逻辑应该对用户 DType 可用,并且不应该是辅助步骤,而是应该取代或成为正常发现的一部分。

讨论#

关于当前状态以及未来数据类型系统可能是什么样子的,已经进行了许多讨论。这些讨论的完整列表很长,有些已经失传,以下列出了一些最近的讨论。

参考文献#