NEP 42 — 新的和可扩展的DType#

标题:

新的和可扩展的DType

作者:

Sebastian Berg

作者:

Ben Nathanson

作者:

Marten van Kerkwijk

状态:

已接受

类型:

标准

创建:

2019-07-17

决议:

https://mail.python.org/pipermail/numpy-discussion/2020-October/081038.html

注意

此NEP是系列中的第三个

  • NEP 40 解释了NumPy的dtype实现的缺点。

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

  • NEP 42(本文档)描述了新设计的与数据类型相关的API。

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

摘要#

NumPy的dtype架构是整体式的——每个dtype都是单个类的实例。没有原则性的方法可以为新的dtype扩展它,并且代码难以阅读和维护。

NEP 41所解释,我们正在提出一个模块化且对用户添加开放的新架构。dtype将派生自一个新的DType类,作为新类型的扩展点。np.dtype("float64")将返回Float64类的实例,它是根类np.dtype的子类。

此NEP是两个NEP中的一个,它们阐述了这个新架构的设计和API。此NEP讨论dtype实现;NEP 43讨论通用函数。

注意

私有和外部API的细节可能会更改以反映用户评论和实现约束。底层原则和选择不应发生重大变化。

动机和范围#

我们的目标是允许用户代码为各种用途创建功能齐全的dtype,从物理单位(例如米)到几何对象的特定于领域的表示。NEP 41描述了这些新的dtype及其好处。

任何支持dtype的设计都必须考虑

  • 创建数组时如何确定形状和dtype

  • 如何存储和访问数组元素

  • 将dtype转换为其他dtype的规则

此外

  • 我们希望dtype包含一个开放给新类型和子层次结构的类层次结构,如NEP 41中所述。

并为此提供

  • 我们需要定义用户API。

所有这些都是此NEP的主题。

  • 类层次结构、它与Python标量类型的关系及其重要属性在nep42_DType 类中描述。

  • 将支持dtype转换的功能在转换中描述。

  • 项访问和存储的实现,以及创建数组时确定形状和dtype的方式,在与Python对象的强制转换中描述。

  • 用户定义自己的DType的功能在公共C-API中描述。

此处和NEP 43中的API完全位于C端。Python端的版本将在未来的NEP中提出。未来的Python API预计将类似,但提供更方便的API来重用现有DType的功能。它还可以提供类似于Python的dataclasses的快捷方式来创建结构化DType。

向后兼容性#

预计中断不会大于典型的NumPy发行版。

  • 主要问题在NEP 41中指出,并且主要会影响NumPy C-API的重度用户。

  • 最终,我们将希望弃用当前用于创建用户定义dtype的API。

  • 细微的、很少被注意到的不一致之处可能会发生变化。示例

    • np.array(np.nan, dtype=np.int64)的行为与np.array([np.nan], dtype=np.int64)不同,后者会引发错误。这可能需要相同的结果(要么都出错,要么都成功)。

    • np.array([array_like])有时与np.array([np.array(array_like)])的行为不同

    • 数组操作可能会也可能不会保留dtype元数据

  • 描述dtype内部结构的文档需要更新。

新代码必须通过NumPy的常规测试套件,从而保证更改与现有代码兼容。

用法和影响#

我们相信本节中的少量结构足以巩固NumPy当前的功能,并支持复杂的用户定义DType。

NEP的其余部分补充了细节,并为该声明提供了支持。

尽管此处使用 Python 进行示例说明,但其实现仅为 C API;未来的 NEP 将处理 Python API。

实现此 NEP 后,可以通过实现以下概述的 DType 基类来创建 DType,该基类在nep42_DType 类中进一步描述。

class DType(np.dtype):
    type : type        # Python scalar type
    parametric : bool  # (may be indicated by superclass)

    @property
    def canonical(self) -> bool:
        raise NotImplementedError

    def ensure_canonical(self : DType) -> DType:
        raise NotImplementedError

对于类型转换,大部分功能由 _castingimpl 中存储的“方法”提供。

    @classmethod
    def common_dtype(cls : DTypeMeta, other : DTypeMeta) -> DTypeMeta:
        raise NotImplementedError

    def common_instance(self : DType, other : DType) -> DType:
        raise NotImplementedError

    # A mapping of "methods" each detailing how to cast to another DType
    # (further specified at the end of the section)
    _castingimpl = {}

对于数组强制转换(也是类型转换的一部分)

    def __dtype_setitem__(self, item_pointer, value):
        raise NotImplementedError

    def __dtype_getitem__(self, item_pointer, base_obj) -> object:
        raise NotImplementedError

    @classmethod
    def __discover_descr_from_pyobject__(cls, obj : object) -> DType:
        raise NotImplementedError

    # initially private:
    @classmethod
    def _known_scalar_type(cls, obj : object) -> bool:
        raise NotImplementedError

类型转换实现的其他元素是 CastingImpl

casting = Union["safe", "same_kind", "unsafe"]

class CastingImpl:
    # Object describing and performing the cast
    casting : casting

    def resolve_descriptors(self, Tuple[DTypeMeta], Tuple[DType|None] : input) -> (casting, Tuple[DType]):
        raise NotImplementedError

    # initially private:
    def _get_loop(...) -> lowlevel_C_loop:
        raise NotImplementedError

它描述了从一种 DType 到另一种 DType 的类型转换。在NEP 43中,此 CastingImpl 对象保持不变,用于支持通用函数。请注意,此处 CastingImpl 的名称将通用地称为 ArrayMethod,以适应类型转换和通用函数。

定义#

dtype#

dtype *实例*;这是附加到 NumPy 数组的对象。

DType#

基类型 np.dtype 的任何子类。

强制转换#

将 Python 类型转换为 NumPy 数组以及存储在 NumPy 数组中的值。

类型转换#

将数组转换为不同的 dtype。

参数类型#

其表示形式可以根据参数值而改变的 dtype,例如具有长度参数的字符串 dtype。当前 flexible dtype 类的所有成员都是参数化的。参见NEP 40

提升#

查找可以对多种 dtype 进行运算而不会丢失信息的 dtype。

安全类型转换#

如果更改类型时不会丢失任何信息,则类型转换是安全的。

在 C 层面上,我们使用 descriptordescr 来表示 *dtype 实例*。在建议的 C API 中,这些术语将区分 dtype 实例和 DType 类。

注意

NumPy 具有现有的标量类型类层次结构,如图中所示 NEP 40,新的 DType 层次结构将类似于它。这些类型用作当前 NumPy 中单个 dtype 类的属性;它们不是 dtype 类。它们既不损害也不有助于这项工作。

DType 类#

本节回顾了所提出的 DType 类背后的结构,包括类型层次结构和抽象 DType 的使用。

类获取器#

要从标量类型创建 DType 实例,用户现在调用 np.dtype(例如,np.dtype(np.int64))。有时还需要访问底层的 DType 类;这在类型提示中尤其重要,因为 DType 实例的“类型”是 DType 类。从类型提示中获得灵感,我们建议使用以下获取器语法

np.dtype[np.int64]

来获取与标量类型对应的 DType 类。此表示法同样适用于内置和用户定义的 DType。

此获取器无需为每个 DType 创建显式名称,从而避免了 np 命名空间的拥挤;获取器本身就表示类型。它还打开了使用注释使 np.ndarray 泛化 DType 类的可能性,例如

np.ndarray[np.dtype[np.float64]]

以上内容相当冗长,因此我们可能会包含诸如

Float64 = np.dtype[np.float64]

numpy.typing 中的别名,从而使注释保持简洁,但仍然避免了如上所述的 np 命名空间的拥挤。对于用户定义的 DType

class UserDtype(dtype): ...

可以执行 np.ndarray[UserDtype],在这种情况下,注释保持简洁,而无需在 NumPy 本身中引入样板代码。对于用户定义的标量类型

class UserScalar(generic): ...

我们需要向 dtype 添加类型重载

@overload
__new__(cls, dtype: Type[UserScalar], ...) -> UserDtype

以允许 np.dtype[UserScalar]

初始实现可能只返回具体的(非抽象)DType。

此项仍在审查中。

层次结构和抽象类#

我们将使用抽象类作为可扩展 DType 类层次结构的构建块。

  1. 抽象类继承干净,原则上允许进行诸如 isinstance(np.dtype("float64"), np.inexact) 之类的检查。

  2. 抽象类允许单个代码段处理多种输入类型。编写的接受 Complex 对象的代码可以处理任何精度的数字;结果的精度由参数的精度决定。

  3. 用户创建的 DType 系列还有空间。我们可以设想一个用于物理单位的抽象 Unit 类,以及一个具体的子类,例如 Float64Unit。调用 Unit(np.float64, "m")m 表示米)将等效于 Float64Unit("m")

  4. NEP 43 中通用函数的实现可能需要类层次结构。

**示例:**NumPy Categorical 类将与 pandas Categorical 对象匹配,后者可以包含整数或一般的 Python 对象。NumPy 需要一个可以为其分配 Categorical 的 DType,但也需要诸如 CategoricalInt64CategoricalObject 之类的 DType,以便 common_dtype(CategoricalInt64, String) 产生错误,但 common_dtype(CategoricalObject, String) 返回 object DType。在我们的方案中,Categorical 是一个抽象类型,具有 CategoricalInt64CategoricalObject 子类。

类结构规则,如下所示

  1. 无法实例化抽象 DType。实例化抽象 DType 会引发错误,或者可能返回具体子类的实例。引发错误将是默认行为,并且最初可能需要这样做。

  2. 虽然抽象 DType 可以是超类,但它们也可以像 Python 的抽象基类 (ABC) 一样允许注册而不是子类化。可能只需使用或继承自 Python ABC。

  3. 具体的 DType 不能被子类化。将来可能会放宽此限制,以允许专门的实现,例如 GPU float64 子类化 NumPy float64。

Julia 语言 对子类化具体类型有类似的禁止。例如,诸如后面的 __common_instance____common_dtype__ 之类的方法,除非经过非常仔细的设计,否则无法用于子类。它有助于避免由于子类化未编写为子类化的类型而导致的实现更改造成的意外漏洞。我们认为 DType API 应该扩展以简化现有功能的包装。

DType 类需要 C 端存储方法和附加信息,这将由 DTypeMeta 类实现。每个 DType 类都是 DTypeMeta 的实例,具有定义明确且可扩展的接口;最终用户会忽略它。

_images/dtype_hierarchy.svg

其他方法和属性#

本节收集 DType 类中未在类型转换和数组强制转换中使用的定义,这些定义将在下面详细描述。

  • 保留现有的 dtype 方法(numpy.dtype) 和 C 端字段。

  • DType.type 替换 dtype.type。除非出现用例,否则 dtype.type 将被弃用。这表示一个 Python 标量类型,它表示与 DType 相同的值。这与在建议的类获取器数组强制转换期间的 DType 发现中使用的类型相同。(这也可以为抽象 DType 设置,这对于数组强制转换是必要的。)

  • 新的 self.canonical 属性概括了字节序的概念,以指示数据是否以默认/规范的方式存储。对于现有代码,“规范”仅表示本机字节序,但它可以在新的 DType 中采用新的含义——例如,区分存储 real - imag 而不是 real + imag 的 Complex 的复共轭实例。ISNBO(“是本机字节序”)标志可能被重新用作规范标志。

  • 包含对参数化 DType 的支持。如果 DType 继承自 ParametricDType,则它将被视为参数化的。

  • DType 方法可能类似于甚至复用现有的 Python 插槽。因此,Python 特殊插槽对于用户自定义的 DType 是禁止使用的(例如,定义Unit("m") > Unit("cm")),因为我们可能希望为这些运算符开发一个对所有 DType 都有效的含义。

  • 排序函数已移至 DType 类。可以通过定义方法dtype_get_sort_function(self, sortkind="stable") -> sortfunction来实现这些函数,如果给定的sortkind未知,则该方法必须返回NotImplemented

  • 无法移除的函数作为特殊方法实现。许多这些函数以前是 PyArray_ArrFuncs dtype 实例 (PyArray_Descr *) 插槽的一部分,包括诸如nonzerofill(用于np.arange)和fromstr(用于解析文本文件)之类的函数。这些旧方法将被弃用,并将添加遵循新设计原则的替代方法。此处未定义 API。由于这些方法可以被弃用并添加新的重命名替代方法,因此如果必须修改这些新方法是可以接受的。

  • 不建议对非内置类型使用kind,建议改用isinstance检查。kind将返回对象的__qualname__以确保所有 DType 的唯一性。在 C 端,kindchar被设置为\0(空字符)。虽然kind将不被鼓励使用,但当前的np.issubdtype可能仍然是此类检查的首选方法。

  • 方法ensure_canonical(self) -> dtype返回一个新的 dtype(或self),其中设置了canonical标志。

  • 由于 NumPy 的方法是通过 unfuncs 提供功能,因此在 DType 中实现的排序之类的函数最终可能会作为通用的 ufuncs 重新实现。

类型转换#

我们在此回顾与数组类型转换相关的操作

我们将展示如何使用astype(new_dtype)实现数组类型转换。

公共 DType 操作#

当输入类型混合时,第一步是找到一个可以保存结果而不会丢失信息的 DType——“公共 DType”。

数组强制转换和连接都返回一个公共 dtype 实例。大多数通用函数使用公共 DType 进行分发,尽管它们可能不将其用于结果(例如,比较的结果始终为 bool)。

我们建议如下实现

  • 对于两个 DType 类

    __common_dtype__(cls, other : DTypeMeta) -> DTypeMeta
    

    返回一个新的 DType,通常是其中一个输入,它可以表示两个输入 DType 的值。这通常应该是最小的:Int16Uint16的公共 DType 是Int32而不是Int64__common_dtype__可以返回 NotImplemented 以推迟到其他方法,并且像 Python 运算符一样,子类优先(首先尝试其__common_dtype__方法)。

  • 对于同一 DType 的两个实例

    __common_instance__(self: SelfT, other : SelfT) -> SelfT
    

    对于非参数化内置 dtype,这将返回self的规范化副本,保留元数据。对于非参数化用户类型,这提供了一个默认实现。

  • 对于不同 DType 的实例,例如>float64S8,该操作分三个步骤完成

    1. Float64.__common_dtype__(type(>float64), type(S8))返回String(或推迟到String.__common_dtype__)。

    2. 类型转换机制(下面详细解释)提供">float64"转换为"S32"的信息。

    3. String.__common_instance__("S8", "S32")返回最终的"S32"

这种移交的好处是减少了重复代码并使关注点分离。DType 实现不需要知道如何进行类型转换,并且类型转换的结果可以扩展到新类型,例如新的字符串编码。

这意味着实现将像这样工作

def common_dtype(DType1, DType2):
    common_dtype = type(dtype1).__common_dtype__(type(dtype2))
    if common_dtype is NotImplemented:
        common_dtype = type(dtype2).__common_dtype__(type(dtype1))
        if common_dtype is NotImplemented:
            raise TypeError("no common dtype")
    return common_dtype

def promote_types(dtype1, dtype2):
    common = common_dtype(type(dtype1), type(dtype2))

    if type(dtype1) is not common:
        # Find what dtype1 is cast to when cast to the common DType
        # by using the CastingImpl as described below:
        castingimpl = get_castingimpl(type(dtype1), common)
        safety, (_, dtype1) = castingimpl.resolve_descriptors(
                (common, common), (dtype1, None))
        assert safety == "safe"  # promotion should normally be a safe cast

    if type(dtype2) is not common:
        # Same as above branch for dtype1.

    if dtype1 is not dtype2:
        return common.__common_instance__(dtype1, dtype2)

其中一些步骤可以针对非参数化 DType 进行优化。

由于__common_dtype__返回的类型不一定是两个参数之一,因此它不等于 NumPy 的“安全”类型转换。安全类型转换适用于np.promote_types(int16, int64),它返回int64,但对于以下情况则失败:

np.promote_types("int64", "float32") -> np.dtype("float64")

DType 作者有责任确保输入可以安全地转换为__common_dtype__

可能存在例外情况。例如,将int32转换为(足够长的)字符串至少目前被认为是“安全的”。但是np.promote_types(int32, String)不会被定义。

示例

object始终选择object作为公共 DType。对于datetime64类型提升,没有其他数据类型定义,但是如果有人实现更高精度的 datetime,那么

HighPrecisionDatetime.__common_dtype__(np.dtype[np.datetime64])

将返回HighPrecisionDatetime,并且如下所述的类型转换实现可能需要决定如何处理 datetime 单位。

替代方案

  • 我们将关于公共 DType 的决策推给了 DType 类。假设我们可以转向基于安全类型转换的通用算法,对 DType 强加一个全序,并返回两个参数都可以安全转换到的第一个类型。

    很难设计一个合理的全序,并且它必须接受新的条目。除此之外,这种方法是有缺陷的,因为导入类型会改变程序的行为。例如,需要int16uint16的公共 DType 的程序通常会得到内置类型int32作为第一个匹配;如果程序添加import int24,则第一个匹配项变为int24,并且较小的类型可能会使程序第一次溢出。[1]

  • 将来可以实现更灵活的公共 DType,其中__common_dtype__依赖于类型转换逻辑中的信息。由于__commond_dtype__是一个方法,因此以后可以添加这样的默认实现。

  • 当然,可以合并处理不同 dtype 的三个步骤。它将失去分离的价值,以换取可能更快的执行速度。但很少有情况会受益。大多数情况,例如数组强制转换,都涉及单个 Python 类型(以及 dtype)。

类型转换操作#

类型转换也许是最复杂和最有趣的 DType 操作。它很像数组上的典型通用函数,将一个输入转换为一个新的输出,有两个区别

  • 类型转换始终需要一个显式的输出数据类型。

  • NumPy 迭代器 API 需要访问比通用函数当前需要的更低级别的函数。

类型转换可能很复杂,并且可能无法实现每个输入数据类型的全部细节(例如非原生字节序或未对齐访问)。因此,复杂的类型转换可能需要三个步骤

  1. 输入数据类型被规范化并准备进行类型转换。

  2. 执行类型转换。

  3. 结果(以规范化形式)被转换为请求的形式(非原生字节序)。

此外,NumPy 提供不同的类型转换种类或安全说明符

  • equivalent,只允许字节序更改

  • safe,需要足够大的类型来保留值

  • same_kind,需要安全转换或同类转换,例如 float64 到 float32

  • unsafe,允许任何数据转换

在某些情况下,类型转换可能只是一个视图。

我们需要支持arr.astype的两个当前签名

  • 对于 DType:arr.astype(np.String)

    • 当前拼写arr.astype("S")

    • np.String可以是抽象 DType

  • 对于 dtype:arr.astype(np.dtype("S8"))

我们还有两个np.can_cast的签名

  • 实例到类:np.can_cast(dtype, DType, "safe")

  • 实例到实例:np.can_cast(dtype, other_dtype, "safe")

在Python级别上,dtype被重载为表示类或实例。

第三个can_cast签名,np.can_cast(DType, OtherDType, "safe"),可能在内部使用,但无需公开给Python。

在DType创建期间,DType将能够传递一个CastingImpl对象的列表,这些对象可以定义到DType的转换和从DType的转换。

其中一个应该定义该DType实例之间的转换。如果DType只有一个实现并且是非参数的,则可以省略。

每个CastingImpl都有一个独特的DType签名

CastingImpl[InputDtype, RequestedDtype]

并实现以下方法和属性

  • 要报告安全性,

    resolve_descriptors(self, Tuple[DTypeMeta], Tuple[DType|None] : input) -> casting, Tuple[DType].

    casting输出报告安全性(安全、不安全或同类),元组用于更复杂的多步骤转换,如下例所示。

  • 要获取转换函数,

    get_loop(...) -> function_to_handle_cast (signature to be decided)

    返回步幅转换函数(“传输函数”)的低级实现,该函数能够执行转换。

    最初,实现将是私有的,用户只能提供具有该签名的步幅循环。

  • 为了性能,一个casting属性取值为equivalentsafeunsafesame-kind

执行转换

_images/casting_flow.svg

上图说明了将值为42int24转换为长度为20的字符串("S20")的多步骤转换。

我们选择了一个实现者只提供有限功能的示例:一个将int24转换为S8字符串(可以容纳所有24位整数)的函数。这意味着需要多次转换。

完整过程是

  1. 调用

    CastingImpl[Int24, String].resolve_descriptors((Int24, String), (int24, "S20")).

    这提供了CastingImpl[Int24, String]仅实现int24"S8"转换的信息。

  2. 由于"S8""S20"不匹配,请使用

    CastingImpl[String, String].get_loop()

    查找将"S8"转换为"S20"的传输(转换)函数。

  3. 使用以下方法获取将int24转换为"S8"的传输函数:

    CastingImpl[Int24, String].get_loop()

  4. 使用两个传输函数执行实际转换

    int24(42) -> S8("42") -> S20("42").

    resolve_descriptors允许实现

    np.array(42, dtype=int24).astype(String)

    调用

    CastingImpl[Int24, String].resolve_descriptors((Int24, String), (int24, None)).

    在这种情况下,(int24, "S8")的结果定义了正确的转换

    np.array(42, dtype=int24).astype(String) == np.array("42", dtype="S8").

转换安全性

要计算np.can_cast(int24, "S20", casting="safe"),只需要resolve_descriptors函数,并且其调用方式与描述转换的图中的方式相同。

在这种情况下,对resolve_descriptors的调用也将提供int24 -> "S8"以及"S8" -> "S20"是安全转换的信息,因此int24 -> "S20"也是安全转换。

在某些情况下,不需要转换。例如,在大多数Linux系统上,np.dtype("long")np.dtype("longlong")是不同的dtype,但都是64位整数。在这种情况下,可以使用long_arr.view("longlong")执行转换。转换是视图的信息将由附加标志处理。因此casting总共有8个值:原始的4个equivalentsafeunsafesame-kind,加上equivalent+viewsafe+viewunsafe+viewsame-kind+view。NumPy目前将dtype1 == dtype2定义为只有在字节序匹配时才为True。此功能可以用“等效”转换和“视图”标志的组合来替换。

(有关resolve_descriptors签名的更多信息,请参见下面的公共C-API部分和NEP 43。)

相同DType实例之间的转换

为了减少转换步骤的数量,CastingImpl必须能够进行该DType的所有实例之间的任何转换。

一般情况下,DType实现者必须包含CastingImpl[DType, DType],除非只有一个单例实例。

一般的多步骤转换

我们可以实现某些转换,例如int8int24,即使用户只提供int16 -> int24转换。此提案没有提供这一点,但未来的工作可能会动态地找到此类转换,或者至少允许resolve_descriptors返回任意dtypes

如果CastingImpl[Int8, Int24].resolve_descriptors((Int8, Int24), (int8, int24))返回(int16, int24),则实际的转换过程可以扩展到包括int8 -> int16转换。这增加了一个步骤。

示例

将整数转换为日期时间的实现通常会说明此转换是不安全的(因为这始终是不安全的转换)。其resolve_descriptors函数可能如下所示:

def resolve_descriptors(self, DTypes, given_dtypes):
   from_dtype, to_dtype = given_dtypes
   from_dtype = from_dtype.ensure_canonical()  # ensure not byte-swapped
   if to_dtype is None:
       raise TypeError("Cannot convert to a NumPy datetime without a unit")
   to_dtype = to_dtype.ensure_canonical()  # ensure not byte-swapped

   # This is always an "unsafe" cast, but for int64, we can represent
   # it by a simple view (if the dtypes are both canonical).
   # (represented as C-side flags here).
   safety_and_view = NPY_UNSAFE_CASTING | _NPY_CAST_IS_VIEW
   return safety_and_view, (from_dtype, to_dtype)

注意

虽然NumPy目前定义了整数到日期时间的转换,但除了无单位的timedelta64之外,最好根本不定义这些转换。一般来说,我们预计用户定义的DType将使用自定义方法,例如unit.drop_unit(arr)arr * unit.seconds

替代方案

  • 我们的设计目标是:- 最小化DType方法的数量并避免代码重复。- 镜像通用函数的实现。

  • 在查找正确的CastingImpl的第一步中仅使用DType类,以及定义CastingImpl.casting的决定,允许保留现有用户定义dtype的__common_dtype__的当前默认实现,这可以在将来扩展。

  • 将步骤分成多个步骤似乎增加了复杂性而不是减少复杂性,但它巩固了np.can_cast(dtype, DTypeClass)np.can_cast(dtype, other_dtype)的签名。

    此外,API保证了用户DType的关注点分离。用户Int24dtype不必处理所有字符串长度(如果它不想这样做)。此外,添加到StringDType的编码不会影响整体转换。resolve_descriptors函数可以继续返回默认编码,而CastingImpl[String, String]可以处理任何必要的编码更改。

  • 主要替代方案是将此处推送到CastingImpl中的大部分信息直接移到DType上的方法中。但这模糊了转换和通用函数之间的相似性。正如下面提到的,它确实减少了间接性。

  • 较早的提案定义了两种方法__can_cast_to__(self, other)来动态返回CastingImpl。这消除了在DType创建(涉及的DType之一)时定义所有可能转换的要求。

    以后可以添加这样的API。它类似于Python的__getattr__,提供了对属性查找的额外控制。

备注

本 NEP 使用 CastingImpl 作为名称,以明确它实现了与类型转换相关的所有功能。它旨在与 NEP 43 中提出的 ArrayMethod 相同,NEP 43 是将 ufunc 重构以处理新的 DType 的一部分。所有类型定义都应命名为 ArrayMethod

CastingImpl 的调度方式最初计划受到限制且完全不透明。将来,它可能会也可能不会被移入一个特殊的 UFunc,或者表现得更像一个通用函数。

与 Python 对象的强制转换#

在数组中存储单个值或将其取出时,需要对其进行强制转换——即将其转换为数组内部的底层表示形式。

强制转换比典型的类型转换略微复杂一些。原因之一是 Python 对象本身可能是一个 0 维数组或具有关联 DType 的标量。

与 Python 标量之间的强制转换需要两到三种方法,这些方法在很大程度上对应于当前的定义

  1. __dtype_setitem__(self, item_pointer, value)

  2. __dtype_getitem__(self, item_pointer, base_obj) -> objectbase_obj 用于内存管理,通常被忽略;它指向拥有数据的对象。它的唯一作用是支持 NumPy 中具有子数组的结构化数据类型,这些数据类型目前返回数组的视图。该函数返回等效的 Python 标量(即通常是 NumPy 标量)。

  3. __dtype_get_pyitem__(self, item_pointer, base_obj) -> object(最初对新的用户定义数据类型隐藏,可在用户请求时公开)。这对应于 arr.item() 方法,该方法也被 arr.tolist() 使用,并返回 Python 浮点数,例如,而不是 NumPy 浮点数。

(以上内容适用于 C-API。Python 端的 API 必须使用字节缓冲区或类似方法来实现这一点,这可能对原型设计有用。)

当某个标量具有已知的(不同的)dtype 时,NumPy 未来可能会使用类型转换而不是 __dtype_setitem__

用户数据类型(最初)应为其自己的 DType.type 和它希望支持的所有基本 Python 标量(例如 intfloat)实现 __dtype_setitem__。将来,可能会公开函数 known_scalar_type,以允许用户 dtype 信号它可以直接存储哪些 Python 标量。

实现:从任意 Python 对象 value 设置数组中单个项目的伪代码实现是(此处的一些函数稍后定义)

def PyArray_Pack(dtype, item_pointer, value):
    DType = type(dtype)
    if DType.type is type(value) or DType.known_scalartype(type(value)):
        return dtype.__dtype_setitem__(item_pointer, value)

    # The dtype cannot handle the value, so try casting:
    arr = np.array(value)
    if arr.dtype is object or arr.ndim != 0:
        # not a numpy or user scalar; try using the dtype after all:
        return dtype.__dtype_setitem__(item_pointer, value)

     arr.astype(dtype)
     item_pointer.write(arr[()])

其中对 np.array() 的调用表示 dtype 的发现,实际上并没有执行。

示例:当前 datetime64 返回 np.datetime64 标量,并且可以从 np.datetime64 分配。但是,datetime __dtype_setitem__ 也允许从日期字符串(“2016-05-01”)或 Python 整数进行分配。此外,datetime __dtype_get_pyitem__ 函数实际上返回一个 Python datetime.datetime 对象(大多数时候)。

替代方案:此功能也可以实现为与 object dtype 的类型转换。但是,强制转换比典型的类型转换略微复杂一些。原因之一是,一般来说,Python 对象本身可能是一个零维数组或具有关联 DType 的标量。这样的对象具有 DType,并且已经定义了正确的转换为另一个 DType 的转换

np.array(np.float32(4), dtype=object).astype(np.float64)

np.array(4, dtype=np.float32).astype(np.float64)

显式实现第一个从 objectnp.float64 的类型转换,需要用户复制或回退到现有的类型转换功能。

当然可以使用通用的类型转换机制来描述与 Python 对象的强制转换,但是 object dtype 特殊且足够重要,因此 NumPy 使用此处介绍的方法来处理它。

进一步的问题和讨论

  • __dtype_setitem__ 函数重复了一些代码,例如从字符串进行强制转换。

    datetime64 允许从字符串赋值,但相同的转换也发生在从字符串 dtype 到 datetime64 的类型转换中。

    我们将来可能会公开 known_scalartype 函数,以允许用户实现这种重复。

    例如,NumPy 通常会使用

    np.array(np.string_("2019")).astype(datetime64)

    但是 datetime64 可以选择出于性能原因使用其 __dtype_setitem__

  • 关于如何处理标量的子类存在一个问题。我们预计将停止自动检测 np.array(float64_subclass) 的 dtype 为 float64。用户仍然可以提供 dtype=np.float64。但是,上述使用 np.array(scalar_subclass).astype(requested_dtype) 的自动类型转换将失败。在许多情况下,这不是问题,因为可以使用 Python __float__ 协议。但在某些情况下,这意味着 Python 标量的子类将表现不同。

注意

示例:np.complex256 未来不应在其 __dtype_setitem__ 方法中使用 __float__,除非它是已知的浮点类型。如果标量是不同高精度浮点类型的子类(例如 np.float128),那么这目前会在没有通知用户的情况下丢失精度。在这种情况下,np.array(float128_subclass(3), dtype=np.complex256) 可能会失败,除非首先将 float128_subclass 转换为 np.float128 基类。

数组强制转换期间的 DType 发现#

使用 NumPy 数组的一个重要步骤是从通用 Python 对象的集合创建数组。

动机:虽然目前区别并不明显,但主要有两个需求

np.array([1, 2, 3, 4.])

需要根据内部的 Python 对象猜测正确的 dtype。只要可以提升,这样的数组可能包含多种数据类型。第二个用例是当用户提供输出 DType 类,但没有提供特定的 DType 实例时

np.array([object(), None], dtype=np.dtype[np.string_])  # (or `dtype="S"`)

在这种情况下,用户指示 object()None 应解释为字符串。对于未来的 Categorical,也需要考虑用户提供的 DType

np.array([1, 2, 1, 1, 2], dtype=Categorical)

它必须将数字解释为唯一的分类值而不是整数。

还有三个需要考虑的问题

  1. 可能需要创建与普通 Python 标量(例如 datetime.datetime)关联的数据类型,这些数据类型还没有 dtype 属性。

  2. 一般来说,数据类型可以表示一个序列,但是,NumPy 目前假设序列始终是元素的集合(序列本身不能是元素)。一个例子是 vector DType。

  3. 数组本身可能包含具有特定 dtype 的数组(甚至是通用的 Python 对象)。例如:np.array([np.array(None, dtype=object)], dtype=np.String) 提出了如何处理包含的数组的问题。

其中一些困难是由于查找输出数组的正确形状和查找正确的数据类型密切相关造成的。

实现:上面有两个不同的情况

  1. 用户没有提供 dtype 信息。

  2. 用户提供了一个 DType 类——例如,由 "S" 表示任意长度的字符串。

在第一种情况下,需要建立从构成元素的 Python 类型到 DType 类的映射。一旦知道 DType 类,就需要找到正确的 dtype 实例。对于字符串,这需要找到字符串长度。

这两种情况将通过利用两条信息来实现

  1. DType.type:当前的类型属性,用于指示哪个 Python 标量类型与 DType 类关联(这是一个始终存在于任何数据类型的属性,并且不限于数组强制转换)。

  2. __discover_descr_from_pyobject__(cls, obj) -> dtype:一个类方法,它根据输入对象返回正确的描述符。请注意,只有参数化 DType 必须实现这一点。对于非参数化 DType,使用默认实例总是可以接受的。

已经通过 DType.type 属性与 DType 关联的 Python 标量类型从 DType 映射到 Python 标量类型。在注册时,DType 可以选择允许自动发现此 Python 标量类型。这需要反向查找,这将使用全局映射(类似字典)来实现

known_python_types[type] = DType

正确的垃圾回收需要额外注意。如果 Python 标量类型(pytype)和DType都是动态创建的,它们可能会再次被删除。为了允许这样做,必须能够使上述映射变为弱引用。这要求pytype显式地持有DType的引用。因此,除了构建全局映射之外,NumPy 还将DType存储为Python类型的pytype.__associated_array_dtype__。这 *不会* 定义映射,并且 *不应该* 直接访问。特别是属性的潜在继承并不意味着 NumPy 会自动使用超类的DType。必须为子类创建一个新的DType

注意

目前,Python 整数没有明确/具体的 NumPy 类型与其关联。这是因为在数组强制转换期间,NumPy 当前在longunsigned longint64unsigned int64object列表中查找第一个能够表示其值的类型(在许多机器上,long 是 64 位)。

相反,它们需要使用AbstractPyInt实现。然后,此 DType 类可以提供__discover_descr_from_pyobject__并返回实际的 dtype,例如np.dtype("int64")。对于 ufunc 中的调度/提升,还需要动态创建AbstractPyInt[value]类(创建可以缓存),以便它们可以提供np.result_type(python_integer, array)[2]提供的基于当前值的提升功能。

为了允许 DType 接受不是基本 Python 类型或DType.type实例的标量作为输入,我们使用known_scalar_type方法。这允许将vector识别为标量(元素)而不是序列(对于命令np.array(vector, dtype=VectorDType)),即使vector本身是一个序列甚至是数组子类。这最初 *不会* 是公共 API,但可能会在以后公开。

示例:当前的 datetime DType 需要一个__discover_descr_from_pyobject__,它返回字符串输入的正确单位。这允许它支持

np.array(["2020-01-02", "2020-01-02 11:24"], dtype="M8")

通过检查日期字符串。结合常见的 dtype 操作,这允许它自动发现 datetime64 单位应该是“分钟”。

NumPy 内部实现:查找正确 dtype 的实现将类似于以下伪代码

def find_dtype(array_like):
    common_dtype = None
    for element in array_like:
        # default to object dtype, if unknown
        DType = known_python_types.get(type(element), np.dtype[object])
        dtype = DType.__discover_descr_from_pyobject__(element)

        if common_dtype is None:
            common_dtype = dtype
        else:
            common_dtype = np.promote_types(common_dtype, dtype)

实际上,np.array()的输入是序列和类似数组对象的混合,因此决定什么是元素需要检查它是否是一个序列。完整的算法(不使用用户提供的 dtype)看起来更像

def find_dtype_recursive(array_like, dtype=None):
    """
    Recursively find the dtype for a nested sequences (arrays are not
    supported here).
    """
    DType = known_python_types.get(type(element), None)

    if DType is None and is_array_like(array_like):
        # Code for a sequence, an array_like may have a DType we
        # can use directly:
        for element in array_like:
            dtype = find_dtype_recursive(element, dtype=dtype)
        return dtype

    elif DType is None:
        DType = np.dtype[object]

    # dtype discovery and promotion as in `find_dtype` above

如果用户提供DType,则将首先尝试此 DType,并且可能需要在执行提升之前转换dtype

限制:嵌套数组np.array([np.array(None, dtype=object)], dtype=np.String)的动机点 3. 目前(有时)通过检查嵌套数组的所有元素来支持。如果嵌套数组的dtypeobject,则用户 DType 将隐式地正确处理这些情况。在其他一些情况下,NumPy 将仅为了保留现有功能的向后兼容性。

>>> np.array([np.array(["2020-05-05"], dtype="S")], dtype=np.datetime64)
array([['2020-05-05']], dtype='datetime64[D]')

它发现 datetime 单位D(天)。用户 DType 无法访问此功能,除非中间转换为object或自定义函数。

使用全局类型映射意味着如果两个 DType 想要映射到相同的 Python 类型,则必须给出错误或警告。在大多数情况下,用户 DType 应该只为同一库中定义的类型实现,以避免潜在的冲突。这将是 DType 实现者的责任,需要谨慎处理这个问题,并在有疑问时避免注册。

替代方案

  • 我们可以依赖标量属性scalar.__associated_array_dtype__,而不是全局映射。这只会改变子类的行为,并且精确的实现最初可以是不确定的。标量将被期望派生自 NumPy 标量。原则上,NumPy 可以暂时继续依赖该属性。

  • 早期针对dtype发现算法的提案使用了双通方法,首先找到正确的DType类,然后才发现参数化的dtype实例。由于其不必要的复杂性而被拒绝。但它本来可以实现通用函数中的基于值的提升,允许

    np.add(np.array([8], dtype="uint8"), [4])
    

    返回uint8结果(而不是int16),这目前发生在

    np.add(np.array([8], dtype="uint8"), 4)
    

    (注意列表[4]而不是标量4)。这不是 NumPy 目前拥有或希望支持的功能。

更多问题和讨论:可以创建诸如分类、数组或向量之类的 DType,只有在提供dtype=DType时才能使用。此类 DType 无法正确往返。例如

np.array(np.array(1, dtype=Categorical)[()])

将导致一个整数数组。要获得原始的Categorical数组,需要显式传递dtype=Categorical。这是一个普遍的限制,但是如果传递dtype=original_arr.dtype,则始终可以实现往返。

公共 C API#

DType 创建#

要创建一个新的 DType,用户需要定义在使用和影响部分中概述并在本提案中详细介绍的方法和属性。

此外,还需要一些类似于PyArray_ArrFuncs中的方法,用于下面的槽结构。

NEP 41中所述,在 C 中定义此 DType 类的接口是根据PEP 384建模的:槽和一些附加信息将传递到槽结构中,并由ssize_t整数标识

static struct PyArrayMethodDef slots[] = {
    {NPY_dt_method, method_implementation},
    ...,
    {0, NULL}
}

typedef struct{
  PyTypeObject *typeobj;    /* type of python scalar or NULL */
  int flags                 /* flags, including parametric and abstract */
  /* NULL terminated CastingImpl; is copied and references are stolen */
  CastingImpl *castingimpls[];
  PyType_Slot *slots;
  PyTypeObject *baseclass;  /* Baseclass or NULL */
} PyArrayDTypeMeta_Spec;

PyObject* PyArray_InitDTypeMetaFromSpec(PyArrayDTypeMeta_Spec *dtype_spec);

所有这些都是通过复制传递的。

待办事项:DType 作者应该能够为 DType 定义新方法,直到定义完整的对象,并且将来可能甚至扩展PyArrayDTypeMeta_Type结构。我们必须决定最初提供什么。一个解决方案可能是只允许从现有类继承:class MyDType(np.dtype, MyBaseclass)。如果np.dtype位于方法解析顺序的第一个位置,这也可以防止对==之类的槽进行不希望的覆盖。

槽将由以NPY_dt_为前缀的名称标识,并且是

  • is_canonical(self) -> {0, 1}

  • ensure_canonical(self) -> dtype

  • default_descr(self) -> dtype(返回必须是本地的,并且通常应该是单例)

  • setitem(self, char *item_ptr, PyObject *value) -> {-1, 0}

  • getitem(self, char *item_ptr, PyObject (base_obj) -> object or NULL

  • discover_descr_from_pyobject(cls, PyObject) -> dtype or NULL

  • common_dtype(cls, other) -> DType, NotImplemented, or NULL

  • common_instance(self, other) -> dtype or NULL

如果省略槽或将其设置为NULL,则会在可能的情况下提供默认实现。非参数化 dtype 不必实现

  • discover_descr_from_pyobject(改用default_descr

  • common_instance(改用default_descr

  • ensure_canonical(改用default_descr)。

排序预计将使用以下方法实现

  • get_sort_function(self, NPY_SORTKIND sort_kind) -> {out_sortfunction, NotImplemented, NULL}.

为方便起见,用户只需实现以下函数即可:

  • compare(self, char *item_ptr1, char *item_ptr2, int *res) -> {-1, 0, 1}

限制:PyArrayDTypeMeta_Spec 结构体扩展起来比较笨拙(例如,通过向 slots 添加版本标记来指示新的、更长的版本)。我们可以使用函数来提供该结构体;这需要内存管理,但允许 ABI 兼容扩展(在创建 DType 时,该结构体会被再次释放)。

CastingImpl#

CastingImpl 的外部 API 最初将仅限于定义

  • casting 属性,它可以是受支持的转换类型之一。这是最安全的转换方式。例如,两种 NumPy 字符串之间的转换通常是“安全的”,但在特定情况下,如果第二个字符串较短,则可能是“相同类型”。如果两种类型都不是参数化的,则 resolve_descriptors 必须使用它。

  • resolve_descriptors(PyArrayMethodObject *self, PyArray_DTypeMeta *DTypes[2], PyArray_Descr *dtypes_in[2], PyArray_Descr *dtypes_out[2], NPY_CASTING *casting_out) -> int {0, -1} 输出 dtype 必须正确设置为带步长循环(转换函数)可以处理的 dtype。最初,结果必须具有与定义 CastingImpl 的 DType 类相同的实例。casting 将设置为 NPY_EQUIV_CASTINGNPY_SAFE_CASTINGNPY_UNSAFE_CASTINGNPY_SAME_KIND_CASTING。可以设置一个新的附加标志 _NPY_CAST_IS_VIEW 来指示不需要转换,视图足以执行转换。当发生错误时,转换应返回 -1。如果转换不可能(但没有发生错误),则应返回 -1 结果 *而无需* 设置错误。*这一点正在考虑中,我们可能会使用 `-1` 来表示一般错误,并使用不同的返回值来表示不可能的转换。* 这意味着无法告知用户为什么转换不可能。

  • strided_loop(char **args, npy_intp *dimensions, npy_intp *strides, ...) -> int {0, -1}(签名将在 NEP 43 中完全定义)

这与 ufunc 的建议 API 相同。签名的附加 ... 部分将包含诸如两个 dtype 之类的信息。内部使用更优化的循环,并将来的版本中会向用户提供这些循环(请参见注释)。

尽管冗长,但 API 将模仿创建新 DType 的 API。

typedef struct{
  int flags;                  /* e.g. whether the cast requires the API */
  int nin, nout;              /* Number of Input and outputs (always 1) */
  NPY_CASTING casting;        /* The "minimal casting level" */
  PyArray_DTypeMeta *dtypes;  /* input and output DType class */
  /* NULL terminated slots defining the methods */
  PyType_Slot *slots;
} PyArrayMethod_Spec;

转换和通用 ufunc 之间的重点有所不同。例如,对于转换,nin == nout == 1 始终正确,而对于 ufunc,casting 通常应为“no”。

注释:我们最初可能只允许用户定义单个循环。NumPy 内部会进行更多优化,这应该通过以下两种方式之一逐步公开:

  • 允许多个版本,例如:

    • 连续内部循环

    • 带步长内部循环

    • 标量内部循环

  • 或者,更有可能的是,公开 get_loop 函数,该函数会传递其他信息,例如固定步长(类似于我们的内部 API)。

  • 转换级别表示最低保证的转换级别,如果转换可能无法进行,则可以为 -1。对于大多数非参数化转换,此值将是转换级别。当结果基于此级别为 True 时,NumPy 可能会跳过 np.can_cast()resolve_descriptors 调用。

此示例尚未包含设置和错误处理。由于这些类似于 UFunc 机制,因此它们将在 NEP 43 中定义,然后以相同的方式合并到转换中。

使用的槽/方法将以 NPY_meth_ 为前缀。

替代方案

  • 除了名称更改和签名调整之外,上述结构似乎没有多少替代方案。使用 *_FromSpec 函数的建议 API 是实现稳定且可扩展 API 的好方法。槽设计是可扩展的,可以在不破坏二进制兼容性的情况下进行更改。仍然可以提供便捷函数以允许使用更少的代码进行创建。

  • 一个缺点是编译器无法警告函数指针不兼容。

实现#

实现步骤在 NEP 41 的“实现”部分中概述。简而言之,我们将首先重写转换和数组强制的内部机制。之后,将逐步添加新的公共 API。我们计划最初以初步状态公开它以获得经验。当前在 dtype 上实现的所有功能都将随着新功能的添加而系统地替换。

替代方案#

可能的实现空间很大,因此进行了许多讨论、构思和设计文档。这些列在 NEP 40 中。上面相关的部分也讨论了替代方案。

参考文献#