NEP 40 — NumPy 中的传统数据类型实现#
- 标题:
NumPy 中的传统数据类型实现
- 作者:
Sebastian Berg
- 状态:
最终版
- 类型:
信息性
- 创建:
2019-07-17
注意
此 NEP 是系列中的第一个
摘要#
作为对后续 NumPy 增强提案 41、42 和 43 的准备。本 NEP 详细说明了截至 NumPy 1.18 的 NumPy 数据类型的当前状态。它描述了一些促使其他提案的技术方面和概念。对于大多数读者来说,更多的一般信息应该从阅读NEP 41开始,仅将本文档用作参考或获取更多详细信息。
详细说明#
本节描述了一些核心概念,并简要概述了当前 dtype 的实现以及讨论。在许多情况下,小节将大致分为首先描述当前实现,然后是“问题和讨论”部分。
参数化数据类型#
某些数据类型本质上是参数化的。所有np.flexible
标量类型都附加到参数化数据类型(字符串、字节和 void)。np.flexible
标量类是可变长度数据类型(字符串、字节和 void)的超类。C 宏PyDataType_ISFLEXIBLE
和PyTypeNum_ISFLEXIBLE
也同样展现了这种区别。这种灵活性推广到可以在数组内表示的值集。例如,"S8"
可以表示比"S4"
更长的字符串。因此,参数化字符串数据类型还将数组内的值限制为字符串标量可以表示的所有值的子集(或子类型)。
基本数值数据类型不是灵活的(不继承自np.flexible
)。float64
、float32
等确实具有字节序,但所描述的值不受其影响,并且始终可以将它们转换为本机规范表示而不会丢失任何信息。
灵活性概念可以推广到参数化数据类型。例如,私有PyArray_AdaptFlexibleDType
函数还接受朴素的 datetime dtype 作为输入以查找正确的时间单位。因此,datetime dtype 在其存储的大小方面不是参数化的,而是在存储的值所代表的内容方面是参数化的。目前np.can_cast("datetime64[s]", "datetime64[ms]", casting="safe")
返回 true,尽管目前尚不清楚这是期望的还是可以推广到可能的未来数据类型,例如物理单位。
因此,我们拥有具有以下属性的数据类型(主要是字符串):
转换并非总是安全的(
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,因为某些操作(例如索引)返回标量而不是 0D 数组。如果多个用户独立决定例如为decimal.Decimal
实现 DType,则这是有问题的。
当前dtype
实现#
当前np.dtype
是一个 Python 类,它的实例是np.dtype(">float64")
等实例。为了设置这些实例的实际行为,全局存储一个原型实例,并根据dtype.typenum
查找。尽可能使用单例。在需要的地方,它会被复制和修改,例如为了更改字节序。
参数化数据类型(字符串、void、datetime 和 timedelta)必须存储其他信息,例如字符串长度、字段或日期时间单位——这些类型的新实例是创建的,而不是依赖单例。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,如果 NumPy 删除替换 TypeResolver 的可能性,它愿意切换到新的 API。
对于用户定义的数据类型,调度逻辑类似,尽管是单独实现且有限制的(见下文讨论)。
问题和讨论#
目前,只有当任何输入(或输出)具有用户数据类型时,才能找到/解析用户定义的函数,因为它使用 OO->O 签名。例如,假设已实现一个 ufunc 循环来实现 fraction_divide(int, int) -> Fraction
,则调用 fraction_divide(4, 5)
(没有特定的输出 dtype)将失败,因为包含用户数据类型 Fraction
(作为输出)的循环只有在任何输入已经是 Fraction
时才能找到。 fraction_divide(4, 5, dtype=Fraction)
可以工作,但不方便。
通常,调度是通过查找第一个匹配的循环来完成的。匹配定义为:所有输入(和可能的输出)都可以安全地转换为签名 typechars(另见当前实现部分)。但是,在某些情况下,安全转换是有问题的,因此明确不允许。例如,np.isnat
函数目前仅针对 datetime 和 timedelta 定义,即使整数被定义为可以安全地转换为 timedelta。如果不是这种情况,调用 np.isnat(np.array("NaT", "timedelta64").astype("int64"))
将返回 true,尽管整数输入数组没有“非时间”的概念。如果一个通用函数,例如 scipy.special
中的大多数函数,仅针对 float32
和 float64
定义,它将当前自动将 float16
静默转换为 float32
(对任何整数输入也是如此)。这确保了成功的执行,但当向 ufunc 添加对新数据类型的支持时,可能会导致输出 dtype 发生更改。当添加 float16
循环时,输出数据类型将当前从 float32
更改为 float16
,而不会发出警告。
一般来说,循环注册的顺序很重要。但是,只有在第一次定义 ufunc 时添加所有循环时,这才是可靠的。当导入新的用户数据类型时添加的额外循环不应对导入的顺序敏感。
有两种主要方法可以更好地定义用户定义类型的类型解析
允许用户 dtype 直接影响循环选择。例如,它们可以提供一个函数,当没有完全匹配的循环可用时,返回/选择一个循环。
定义所有实现/循环的总排序,可能基于“安全转换”语义或类似的语义。
虽然选项 2 的推理可能不太复杂,但仍有待观察它是否足以满足所有(或大多数)用例。
调整 UFuncs 中的参数化输出 DTypes#
当前在 TypeResolver
中执行参数化 dtype 的第二个步骤:datetime 和 timedelta 数据类型必须决定操作和输出数组的正确参数。此步骤还需要仔细检查所有转换是否可以安全执行,默认情况下这意味着它们是“同类”转换。
问题和讨论#
修复正确的输出 dtype 目前是类型解析的一部分。但是,这是一个不同的步骤,应该在实际的类型/循环解析发生后作为这样的步骤来处理。
因此,此步骤可能会从上述调度步骤移动到下面描述的特定于实现的代码。
UFunc 的特定于 DType 的实现#
一旦找到正确的实现/循环,UFunc 当前会调用一个用 C 编写的单个内部循环函数。可以多次调用它来完成完整的计算,并且它关于当前上下文的信息很少或没有。它还有一个 void 返回值。
问题和讨论#
参数化数据类型可能需要向内部循环函数传递附加信息以决定如何解释数据。这就是目前不存在 string
dtype 的通用函数的原因(尽管在 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
使用
element.dtype
从数组(或类似数组)或 NumPy 标量中获取对已知的 Python 类型使用
isinstance(..., float)
(请注意,这些规则意味着子类当前有效)。将元组强制转换为 void 数据类型的特殊规则。
使用
np.promote_types
使用下一个元素的 dtype 来提升当前 dtype。如果找到字符串,则会重新启动整个过程(另见 [gh-15327]),方式类似于给出
dtype="S"
(见下文)。
如果给出 dtype=...
,则使用此 dtype 不作修改,除非它是不明确的参数化 dtype 实例,这意味着“S0”、“V0”、“U0”、“datetime64”和“timdelta64”。因此,这些是灵活的数据类型,长度不为 0(被认为是无大小的)——以及没有附加单位的日期时间或 timedelta(“通用单位”)。
在未来的 DType 类层次结构中,这些可能由类而不是特殊的实例来表示,因为这些特殊的实例通常不应该附加到数组上。
例如,如果提供了这样的参数化dtype实例(例如使用dtype="S"
),则会调用PyArray_AdaptFlexibleDType
,并使用特定于DType的逻辑有效地检查所有值。也就是说
字符串将使用
str(element)
来查找大多数元素的长度Datetime64能够从字符串进行强制转换并猜测正确的单位。
讨论和问题#
在正常的发现过程中,isinstance
似乎应该更严格地进行type(element) is desired_type
检查。此外,当前的AdaptFlexibleDType
逻辑应该提供给用户DType,而不是作为次要步骤,而是应该替换或成为正常发现的一部分。
讨论#
关于当前状态以及未来数据类型系统可能是什么样子,已经进行了许多讨论。这些讨论的完整列表很长,有些已经遗失,以下提供最近的一些讨论:
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的调用约定以及对更强大的UFuncs的需求的讨论: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
还列出了一些需求和一些实现想法
参考文献#
版权#
本文档已进入公有领域。