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 标量类型都附属于参数化数据类型(字符串、字节和 void)。标量 np.flexible 类是可变长度数据类型(字符串、字节和 void)的超类。这种区别也通过 C 宏 PyDataType_ISFLEXIBLEPyTypeNum_ISFLEXIBLE 暴露出来。这种灵活性可以泛化到数组中可以表示的值集。例如,"S8" 可以表示比 "S4" 更长的字符串。因此,参数化的字符串数据类型还将数组中的值限制为所有可以由字符串标量表示的值的子集(或子类型)。

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

灵活性概念可以泛化到参数化数据类型。例如,私有的 PyArray_AdaptFlexibleDType 函数还接受朴素的 datetime dtype 作为输入以查找正确的时间单位。因此,datetime 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)很重要。通用函数目前依赖于安全类型转换语义来决定使用哪个循环,从而决定输出数据类型。

问题与讨论#

似乎有一些共识认为当前方法对于具有数据类型的值并非理想,但对于纯 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。为了改善这种情况,似乎有必要轻松创建 object 数据类型,它们表示特定的 Python 数据类型,并以指向 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 不兼容的更改是不可能的。此结构保存重要信息,例如如何复制或转换,并为函数指针提供空间,例如比较元素、转换为布尔值或排序。由于其中一些函数是矢量化操作,作用于多个元素,它们符合 ufunc 的模型,将来无需在数据类型上定义。例如 np.clip 函数以前是使用 PyArray_ArrFuncs 实现的,现在则作为 ufunc 实现。

讨论与问题#

当前数据类型函数实现的一个进一步问题是,与方法不同,它们在被调用时不会传递 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 转换为另一种(均以本机字节序)。这些 C 级函数可以注册以将对齐和连续的内存从一种 dtype 转换为另一种。可以为函数提供两个数组(尽管对于标量,参数有时为 NULL)。NumPy 将确保这些函数接收本机字节序的输入。当前实现将函数存储在要转换的 dtype 的 C 数组中,或者在转换为用户定义(自定义)数据类型时存储在字典中。

通常,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,如果 NumPy 移除替换 TypeResolver 的可能性,Astropy 将愿意切换到新的 API。

对于用户定义的(自定义)数据类型,分派逻辑类似,尽管实现是分开的且有限的(见下文讨论)。

问题与讨论#

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

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

总的来说,循环注册的顺序很重要。然而,这只有在所有循环在 ufunc 首次定义时添加时才可靠。在新用户数据类型导入时添加的附加循环不应受到导入顺序的影响。

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

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

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

虽然选项 2 可能没有选项 1 那么复杂,但尚不确定它是否足以满足所有(或大多数)用例。

ufunc 中参数化输出 DTypes 的调整#

参数化数据类型所需的第二个步骤目前在 TypeResolver 中执行:datetime 和 timedelta 数据类型必须决定操作和输出数组的正确参数。此步骤还需要仔细检查所有转换是否可以安全执行,默认情况下意味着它们是“相同种类”的转换。

问题与讨论#

确定正确的输出 dtype 目前是类型解析的一部分。然而,这是一个不同的步骤,在实际的类型/循环解析发生后,应该将其作为单独的步骤来处理。

因此,此步骤可能会从分派步骤(如上所述)移动到下面描述的特定于实现的(implementation-specific)代码。

ufunc 的数据类型特定实现#

一旦找到正确的实现/循环,UFunc 目前会调用一个用 C 编写的单个内部循环函数。为了完成计算,它可能会被多次调用,并且它几乎没有或根本没有当前上下文的信息。它还有一个 void 返回值。

问题与讨论#

参数化数据类型可能需要将额外信息传递给内部循环函数,以决定如何解释数据。这就是为什么目前没有 string 数据类型的通用函数(尽管在 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 数组时,需要检查所有对象以找到正确的数据类型。np.array() 的输入是潜在的嵌套 Python 序列,它们以通用 Python 对象的形式保存最终元素。NumPy 必须解包所有嵌套序列,然后检查元素。最终的数据类型是通过迭代所有将进入数组的元素来找到的,并且

  1. 发现单个元素的 dtype

    • 来自数组(或类似数组)或 NumPy 标量,使用 element.dtype

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

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

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

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

如果给出 dtype=...,则此 dtype 将被直接使用,除非它是一个不特定的参数化 dtype 实例,表示“S0”、“V0”、“U0”、“datetime64”和“timedelta64”。这些因此是长度为 0 的灵活数据类型——被视为无大小——以及没有单位附加的日期时间或时间差(“通用单位”)。

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

如果提供了这样的参数化 dtype 实例,例如使用 dtype="S",则会调用 PyArray_AdaptFlexibleDType,它实际上会使用特定于 DType 的逻辑来检查所有值。也就是说:

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

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

讨论与问题#

在正常发现过程中,isinstance 应该是严格的 type(element) is desired_type 检查。此外,当前的 AdaptFlexibleDType 逻辑应该对用户 DType 可用,而不是作为第二步,而是应该替换正常发现,或者成为其一部分。

讨论#

关于当前状态以及未来数据类型系统可能是什么样子,已经进行了许多讨论。这些讨论的完整列表很长,有些已经随时间流逝,以下提供了一些近期讨论的子集。

参考文献#