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 是阐述此新架构设计和 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 端。未来的 NEP 将提出一个 Python 端的版本。未来的 Python API 预计会相似,但会提供更方便的 API 来重用现有 DType 的功能。它还可以提供创建结构化 DType 的快捷方式,类似于 Python 的 dataclasses

向后兼容性#

预计中断程度不会超过典型的 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` 的任何子类。

强制转换(coercion)#

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

类型转换(cast)#

将数组转换为不同 dtype 的过程。

参数化类型#

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

提升(promotion)#

找到一个可以在混合 dtype 上执行操作而不损失信息的 dtype。

安全类型转换(safe cast)#

如果在更改类型时没有信息丢失,则该类型转换是安全的。

在 C 层面,我们使用 `descriptor` 或 `descr` 来表示 *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 都同样适用。

此获取器消除了为每个 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,但它也需要 `CategoricalInt64` 和 `CategoricalObject` 这样的 DType,使得 `common_dtype(CategoricalInt64, String)` 引发错误,而 `common_dtype(CategoricalObject, String)` 返回一个 `object` DType。在我们的方案中,`Categorical` 是一个抽象类型,具有 `CategoricalInt64` 和 `CategoricalObject` 子类。

类结构规则,图示 如下

  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` 属性将字节顺序的概念泛化,以指示数据是否以默认/规范方式存储。对于现有代码,“canonical”仅表示本地字节顺序,但在新的 DType 中,它可以具有新的含义——例如,用于区分 Complex 的共轭实例,该实例存储 `real - imag` 而非 `real + imag`。ISNBO(“is native byte order”)标志可能会被重新用作 canonical 标志。

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

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

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

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

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

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

  • 由于 NumPy 的方法是通过 ufuncs 提供功能,因此将在 DType 中实现的排序等功能最终可能会被重新实现为广义 ufuncs。

类型转换(Casting)#

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

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

通用 DType 操作#

当输入类型混合时,第一步是找到一个能够容纳结果而不损失信息的 DType——一个“通用 DType”。

数组强制转换和连接都返回一个通用 dtype 实例。大多数通用函数使用通用 DType 进行调度,尽管它们可能不将其用于结果(例如,比较的结果总是布尔值)。

我们提出以下实现:

  • 对于两个 DType 类:

    __common_dtype__(cls, other : DTypeMeta) -> DTypeMeta
    

    返回一个新的 DType,通常是其中一个输入,它可以表示两个输入 DType 的值。这通常应该是最小的:`Int16` 和 `Uint16` 的通用 DType 是 `Int32` 而不是 `Int64`。`__common_dtype__` 可能会返回 NotImplemented 以委托给其他,并且像 Python 运算符一样,子类优先(它们的 `__common_dtype__` 方法首先被尝试)。

  • 对于同一 DType 的两个实例:

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

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

  • 对于不同 DType 的实例,例如 `>float64` 和 `S8`,操作分三步完成:

    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 施加全序关系,并返回两个参数都可以安全地转换到的第一个类型。

    很难设计一个合理的全序关系,而且它必须接受新的条目。除此之外,这种方法是有缺陷的,因为导入一个类型可以改变程序的行为。例如,一个程序需要 `int16` 和 `uint16` 的通用 DType,通常会首先得到内置类型 `int32`;如果程序添加了 `import int24`,那么第一个匹配项就变成了 `int24`,而且较小的类型可能首次导致程序溢出。[1]

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

  • 当然,对不同 dtype 的三步处理可以合并。这样做会失去拆分的价值,以换取可能更快的执行速度。但只有少数情况会从中受益。大多数情况,例如数组强制转换,涉及单个 Python 类型(因此是 dtype)。

类型转换操作#

类型转换可能是最复杂和有趣的 DType 操作。它很像数组上的典型通用函数,将一个输入转换为一个新的输出,但有两点不同:

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

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

类型转换可能很复杂,并且可能不会实现每个输入数据类型的所有细节(例如非本地字节顺序或未对齐访问)。因此,复杂的类型转换可能需要 3 个步骤:

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

  2. 执行类型转换。

  3. 结果(规范化形式)被转换为请求的形式(非本地字节顺序)。

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

  • `equivalent`(等价),只允许字节顺序更改

  • `safe`(安全),要求类型足够大以保留值

  • `same_kind`(同类),要求安全类型转换或同类内部类型转换,如 float64 到 float32

  • `unsafe`(不安全),允许任何数据转换

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

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

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

    • 当前写法 `arr.astype("S")`

    • `np.String` 可以是抽象 DType。

  • 对于 dtypes:`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 只有单个实现且是非参数化的,则可以省略。

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

CastingImpl[InputDtype, RequestedDtype]

并实现以下方法和属性:

  • 为了报告安全性:

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

    `casting` 输出报告安全性(safe、unsafe 或 same-kind),元组用于更多多步类型转换,如下例所示。

  • 获取类型转换函数:

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

    返回一个分步(strided)类型转换函数(“传输函数”)的低级实现,能够执行类型转换。

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

  • 为了性能,一个 `casting` 属性,其值可以是 `equivalent`、`safe`、`unsafe` 或 `same-kind`。

执行类型转换

_images/casting_flow.svg

上图展示了一个 `int24`(值为 `42`)到长度为 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 个 `equivalent`、`safe`、`unsafe` 和 `same-kind`,加上 `equivalent+view`、`safe+view`、`unsafe+view` 和 `same-kind+view`。NumPy 目前定义 `dtype1 == dtype2` 为 True 仅当字节顺序匹配时。此功能可以通过“equivalent”类型转换和“view”标志的组合来替换。

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

相同 DType 实例之间的类型转换

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

通常,DType 实现者必须包含 `CastingImpl[DType, DType]`,除非只有一个单例实例。

通用多步类型转换

即使用户只提供了 `int16 -> int24` 类型转换,我们也可以实现某些类型转换,例如 `int8` 到 `int24`。本提案不提供此功能,但未来的工作可能会动态查找此类类型转换,或者至少允许 `resolve_descriptors` 返回任意 `dtypes`。

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

示例

将整数转换为 datetime 的实现通常会声明此类型转换是不安全的(因为它始终是不安全的类型转换)。它的 `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 目前定义了整数到 datetime 的类型转换,但除了无单位的 `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 的关注点分离。用户 `Int24` dtype 如果不愿意,不必处理所有字符串长度。此外,添加到 `String` DType 的编码不会影响整体类型转换。`resolve_descriptors` 函数可以继续返回默认编码,并且 `CastingImpl[String, String]` 可以处理任何必要的编码更改。

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

  • 早期的一项提案定义了两个方法 `__can_cast_to__(self, other)`,用于动态返回 `CastingImpl`。这消除了在 DType 创建时(涉及的 DType 之一)定义所有可能类型转换的要求。

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

注意事项

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

`CastingImpl` 的调度方式最初计划是有限且完全不透明的。未来,它可能会或可能不会被移入一个特殊的 UFunc,或更像一个通用函数。

Python 对象之间的强制转换#

当在数组中存储单个值或从中取出值时,需要将其强制转换(即转换)为数组内部的低级表示,或从低级表示转换出来。

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

Python 标量之间的强制转换需要两到三个方法,这些方法大体上与当前定义相对应:

  1. __dtype_setitem__(self, item_pointer, value)

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

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

(以上内容针对 C-API。Python 端的 API 将不得不使用字节缓冲区或类似机制来实现此功能,这可能对原型设计有用。)

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

用户数据类型(最初)应为其自身的 `DType.type` 和所有它希望支持的基本 Python 标量(例如 `int` 和 `float`)实现 `__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)

显式实现第一个 `object` 到 `np.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` 应被解释为字符串。对用户提供的 DType 的考虑也出现在未来的 `Categorical` 中:

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`:当前类型属性,用于指示与 DType 类关联的 Python 标量类型(这是一个*类*属性,始终存在于任何数据类型中,并且不限于数组强制转换)。

  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 作为 pytype.__associated_array_dtype__ 存储在 Python 类型中。这*不*定义映射,也*不*应该直接访问。特别是,属性的潜在继承并不意味着 NumPy 会自动使用超类的 DType。必须为子类创建一个新的 DType

注意

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

相反,它们将需要使用 AbstractPyInt 来实现。此 DType 类可以提供 __discover_descr_from_pyobject__ 并返回实际的 dtype,例如 np.dtype("int64")。对于 ufuncs 中的调度/提升,还需要动态创建 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() 的输入是序列和类数组对象的混合体,因此决定什么是元素需要检查它是否是序列。因此,完整的算法(没有用户提供的 dtypes)看起来更像是

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. 目前(有时)通过检查嵌套数组的所有元素来支持。如果嵌套数组是 object dtype,用户 DType 将隐式地正确处理这些。在其他一些情况下,NumPy 将仅保留现有功能的向后兼容性。NumPy 使用此类功能来允许类似以下的代码:

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

它发现 datetime 单位 D(天)。如果没有中间转换为 object 或自定义函数,用户 DType 将无法访问此可能性。

使用全局类型映射意味着如果两个 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 目前拥有或希望支持的功能。

进一步的问题和讨论:可以创建诸如 Categorical、array 或 vector 之类的 DType,它们只有在提供了 dtype=DType 时才能使用。此类 DType 无法正确往返。例如:

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

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

公共 C-API#

DType 创建#

要创建新的 DType,用户需要定义 用法和影响 部分中概述的以及本提案中详细说明的方法和属性。

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

NEP 41 中所述,在 C 中定义此 DType 类的接口是模仿 PEP 384:Slots 和一些额外信息将通过一个 slot 结构传递,并由 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 在方法解析顺序中排在第一位,这也可以防止不希望发生的 == 等 slot 的覆盖。

slots 将由以 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

如果 slot 被省略或设置为 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} 输​​出 dtypes 必须正确设置为步进循环(传输函数)可以处理的 dtypes。最初,结果必须具有与 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 中完整定义)

这与 ufuncs 的提议 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;

类型转换和通用 ufuncs 之间的侧重点不同。例如,对于类型转换,nin == nout == 1 总是正确的,而对于 ufuncs,casting 通常预期为“否”。

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

  • 允许多个版本,例如:

    • 连续内循环

    • 步进内循环

    • 标量内循环

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

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

该示例尚不包括设置和错误处理。由于这些与 UFunc 机制类似,它们将在 NEP 43 中定义,然后以相同的方式整合到类型转换中。

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

替代方案

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

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

实现#

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

替代方案#

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

参考文献#