NEP 40 — NumPy 中的传统数据类型实现#
- 标题:
NumPy 中的传统数据类型实现
- 作者:
Sebastian Berg
- 状态:
最终
- 类型:
信息性
- 创建:
2019-07-17
注意
此 NEP 是一个系列中的第一个
摘要#
作为对后续 NumPy 增强提案 41、42 和 43 的准备。此 NEP 详细说明了截至 NumPy 1.18 的 NumPy 数据类型的当前状态。它描述了激励其他提案的一些技术方面和概念。对于大多数读者,应首先阅读 NEP 41,并将本文档仅用作参考或用于获取更多详细信息。
详细描述#
本节描述了一些核心概念,并简要概述了当前的 dtype 实现,以及讨论。在许多情况下,小节将大致分为两部分,首先描述当前的实现,然后是“问题和讨论”部分。
参数化数据类型#
某些数据类型本质上是参数化的。所有 np.flexible
标量类型都附加到参数化数据类型(字符串、字节和空)。类 np.flexible
对于标量是可变长度数据类型(字符串、字节和空)的超类。这种区别同样通过 C 宏 PyDataType_ISFLEXIBLE
和 PyTypeNum_ISFLEXIBLE
公开。这种灵活性推广到可以在数组中表示的值集。例如,"S8"
可以表示比 "S4"
更长的字符串。因此,参数化字符串数据类型还将数组中的值限制为所有字符串标量可以表示的值的子集(或子类型)。
基本数值数据类型不是灵活的(不继承自 np.flexible
)。float64
、float32
等确实具有字节顺序,但描述的值不受字节顺序的影响,并且始终可以将它们转换为本地的规范表示而不会丢失任何信息。
灵活性概念可以推广到参数化数据类型。例如,私有 PyArray_AdaptFlexibleDType
函数还接受朴素的日期时间 dtype 作为输入以找到正确的时间单位。因此,日期时间 dtype 在存储大小上不是参数化的,而是在存储的值表示上是参数化的。目前,np.can_cast("datetime64[s]", "datetime64[ms]", casting="safe")
返回真值,尽管目前尚不清楚这是想要的还是可以推广到未来的数据类型,例如物理单位。
因此,我们拥有具有以下属性的数据类型(主要是字符串):
转换并不总是安全的 (
np.can_cast("S8", "S4")
)数组强制转换应该能够发现确切的 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 值(最初没有数据类型)被表示为 int8
或 int16
(最小的可能数据类型)。
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.uint8
和 np.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,因为某些操作(例如索引)返回标量而不是 0 维数组。如果多个用户独立决定为decimal.Decimal
实现 DType,这将是一个问题。
当前 dtype
实现#
目前 np.dtype
是一个 Python 类,其实例是 np.dtype(">float64")
等实例。为了设置这些实例的实际行为,一个原型实例会全局存储起来,并根据 dtype.typenum
进行查找。单例在可能的情况下被使用。在必要时,它会被复制和修改,例如为了改变字节序。
参数化数据类型(字符串、空值、日期时间和时间差)必须存储额外的信息,例如字符串长度、字段或日期时间单位——这些类型的新的实例会被创建,而不是依赖于单例。NumPy 中的所有当前数据类型进一步支持在创建期间设置元数据字段,该字段可以设置为任意的字典值,但在实践中似乎很少使用(最近一个突出的用户是 h5py)。
许多特定于数据类型的函数是在一个名为 PyArray_ArrFuncs
的 C 结构体中定义的,该结构体是每个 dtype
实例的一部分,与 Python 的 PyNumberMethods
类似。对于用户定义的数据类型,这个结构体对用户公开,使得 ABI 兼容的更改变得不可能。这个结构体包含重要信息,例如如何复制或转换,并为函数指针提供空间,例如比较元素、转换为布尔值或排序。由于其中一些函数是向量化操作,作用于多个元素,因此它们符合 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)
。
NumPy 标量尝试模拟具有固定数据类型的零维数组。对于数值(和 unicode)数据类型,它们进一步限制为本机字节序。
当前的转换实现#
数据类型需要支持的主要功能之一是在彼此之间进行转换,使用 arr.astype(new_dtype, casting="unsafe")
,或者在执行具有不同类型(例如添加整数和浮点数)的 ufunc 时。
转换表确定是否可以将一种特定类型转换为另一种特定类型。然而,通用转换规则无法处理参数化 dtype,例如字符串。参数化 dtype 的逻辑主要在 PyArray_CanCastTo
中定义,目前无法为用户定义的数据类型定制。
实际的转换有两个不同的部分。
copyswap
/copyswapn
为每个 dtype 定义,可以处理非本机字节序的字节交换以及未对齐的内存。通用转换代码由 C 函数提供,这些函数知道如何将对齐且连续的内存从一种 dtype 转换为另一种 dtype(都是本机字节序)。这些 C 级函数可以被注册以将对齐且连续的内存从一种 dtype 转换为另一种 dtype。函数可能会被提供两个数组(尽管参数有时对于标量来说是
NULL
)。NumPy 将确保这些函数接收本机字节序输入。当前的实现将函数存储在要转换的数据类型的 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,它愿意切换到新的 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"))
目前将返回 true,尽管整数输入数组没有“非时间”的概念。如果一个通用函数,例如 scipy.special
中的大多数函数,只针对 float32
和 float64
定义,它目前将自动将 float16
静默转换为 float32
(类似于任何整数输入)。这确保了成功的执行,但当对 ufunc 添加对新数据类型的支持时,可能会导致输出 dtype 发生变化。当添加 float16
循环时,输出数据类型目前将在没有警告的情况下从 float32
更改为 float16
。
一般来说,循环注册的顺序很重要。但是,这只有在首次定义 ufunc 时添加所有循环时才可靠。当导入新的用户数据类型时添加的附加循环不应对导入发生的顺序敏感。
有两种主要方法可以更好地定义用户定义类型的类型解析
允许用户数据类型直接影响循环选择。例如,它们可以提供一个函数,当没有可用的精确匹配循环时,该函数返回/选择一个循环。
定义所有实现/循环的总顺序,可能基于“安全转换”语义,或类似的语义。
虽然选项 2 可能更易于推理,但还需要看看它是否足以满足所有(或大多数)用例。
UFuncs 中参数化输出 DTypes 的调整#
参数化数据类型所需的第二个步骤目前在 TypeResolver
中执行:日期时间和时间间隔数据类型必须决定操作和输出数组的正确参数。此步骤还需要再次检查所有转换是否可以安全地执行,默认情况下意味着它们是“同类”转换。
问题和讨论#
修复正确的输出 dtype 目前是类型解析的一部分。但是,它是一个不同的步骤,并且应该在实际类型/循环解析发生后作为这样的步骤进行处理。
因此,此步骤可能会从上面描述的调度步骤移动到下面描述的特定于实现的代码。
UFunc 的特定于 DType 的实现#
一旦找到正确的实现/循环,UFuncs 目前会调用一个用 C 编写的单个内部循环函数。这可能会被多次调用以进行完整的计算,并且它对当前上下文的信息很少或没有。它还有一个 void 返回值。
问题和讨论#
参数化数据类型可能需要将附加信息传递给内部循环函数以决定如何解释数据。这就是目前没有针对 string
数据类型的通用函数存在的原因(尽管在 NumPy 本身中技术上可行)。请注意,目前可以传入输入数组对象(在没有必要进行转换的情况下,这些对象反过来会保存数据类型)。但是,不应该需要完整的数组信息,并且目前在任何转换发生之前,数组都会被传入。此功能在 NumPy 中未使用,也没有已知的用户。
另一个问题是内部循环函数中的错误报告。目前有两种方法可以做到这一点
通过设置 Python 异常
使用 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 必须解包所有嵌套序列,然后检查元素。最终数据类型是通过迭代所有最终将包含在数组中的元素,并且
发现单个元素的 dtype
从数组(或类似数组)或 NumPy 标量使用
element.dtype
使用
isinstance(..., float)
用于已知的 Python 类型(请注意,这些规则意味着子类目前是有效的)。用于强制转换元组的 void 数据类型的特殊规则。
使用
np.promote_types
使用下一个元素的 dtype 提升当前 dtype。如果找到字符串,则整个过程将重新开始(另请参见 [gh-15327]),类似于提供
dtype="S"
(参见下面)。
如果提供 dtype=...
,则将使用此 dtype 不做修改,除非它是一个非特定的参数化 dtype 实例,这意味着“S0”、“V0”、“U0”、“datetime64”和“timdelta64”。因此,这些是灵活的数据类型,没有长度 0(被认为是无大小的)以及没有附加单位的日期时间或时间间隔(“通用单位”)。
在将来的 DType 类层次结构中,这些可能由类而不是特殊实例表示,因为这些特殊实例通常不应附加到数组。
如果为例如使用 dtype="S"
提供了这样一个参数化 dtype 实例,则会调用 PyArray_AdaptFlexibleDType
并有效地使用特定于 DType 的逻辑检查所有值。也就是说
字符串将使用
str(element)
来查找大多数元素的长度Datetime64 能够从字符串强制转换并猜测正确的单位。
讨论和问题#
在正常的发现过程中,isinstance
应该更严格地进行 type(element) is desired_type
检查。此外,当前的 AdaptFlexibleDType
逻辑应该对用户 DTypes 可用,并且不应该是次要步骤,而是应该替换或成为正常发现的一部分。
讨论#
关于当前状态以及未来数据类型系统可能是什么样子的,已经进行了很多讨论。这些讨论的完整列表很长,其中一些已经随着时间的推移而丢失,以下提供了一些最近讨论的子集。
Stephan Hoyer 在开发者会议后撰写的 NEP 草案(在接下来的开发者会议上更新)https://hackmd.io/6YmDt_PgSVORRNRxHyPaNQ
之前收集的相关文档列表https://hackmd.io/UVOtgj1wRZSsoNQCjkhq1g(待办事项:缩减到最重要的文档)
numpy/numpy#12630 Matti Picus 的 NEP 草案,从
ArrFunctions
的角度讨论了子类化的技术方面。https://hackmd.io/ok21UoAQQmOtSVk6keaJhw 和 https://hackmd.io/s/ryTFaOPHE(2019-04-30)关于子类化实现方法的建议。
关于 ufunc 的调用约定以及对更强大 UFunc 的需求的讨论:numpy/numpy#12518
2018-11-30 开发者会议笔记:BIDS-numpy/docs 以及随后的 NEP 草案:https://hackmd.io/6YmDt_PgSVORRNRxHyPaNQ
2018 年 11 月 30 日的 BIDS 会议以及 Stephan Hoyer 关于 NumPy 应该提供什么以及如何实现这些目标的文档。与 Eric Wieser、Matti Picus、Charles Harris、Tyler Reddy、Stéfan van der Walt 和 Travis Oliphant 进行了会面。
SciPy 2018 头脑风暴会议,包括用例摘要:numpy/numpy
还列出了一些要求和一些实现想法。
参考文献#
版权#
本文档已置于公共领域。