NEP 42 — 新的可扩展数据类型#

标题:

新的可扩展数据类型

作者:

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 中的一个,这两个 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. 抽象类允许使用一段代码处理多种输入类型。编写用于接受复数对象的代码可以处理任何精度的数字;结果的精度由参数的精度决定。

  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 中获得新的含义——例如,区分 Complex 的复共轭实例,该实例存储 real - imag 而不是 real + imag。ISNBO(“是否为本机字节序”)标志可能会被重新用作规范标志。

  • 包含对参数化 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_ArrFuncs 槽(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 类型提升,它与其他数据类型没有定义,但如果有人要实现一个新的更高精度的日期时间,那么

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

将返回 HighPrecisionDatetime,并且类型转换实现(如下所述)可能需要决定如何处理日期时间单位。

备选方案

  • 我们将关于公共 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 的两个现有签名。

  • 对于数据类型:arr.astype(np.String)

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

    • np.String 可以是一个抽象数据类型

  • 对于数据类型: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。

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

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

每个 CastingImpl 都有一个不同的数据类型签名

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") 是不同的数据类型,但都是 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。)

相同数据类型实例之间的转换

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

通常,数据类型实现者必须包含 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 之外,最好根本不定义这些转换。通常,我们期望用户定义的数据类型将使用自定义方法,例如 unit.drop_unit(arr)arr * unit.seconds

备选方案

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

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

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

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

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

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

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

注释

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

CastingImpl 的分派方式最初计划是有限的并且完全不透明。将来,它可能会或可能不会移动到一个特殊的 UFunc 中,或者表现得更像一个通用函数。

与 Python 对象之间的强制转换#

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

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

与 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 必须使用字节缓冲区或类似方法来实现这一点,这可能对原型设计有用。)

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

用户数据类型(最初)预计将为其自己的 DType.type 和它希望支持的所有基本 Python 标量(例如 intfloat)实现 __dtype_setitem__。将来,可以公开一个函数 known_scalar_type 以允许用户数据类型指示它可以直接存储哪些 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() 的调用表示数据类型发现,实际上并没有执行。

示例:当前的 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 应解释为字符串。考虑用户提供的 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:当前的 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 还将在 Python 类型中将 DType 存储为 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. 当前(有时)通过检查嵌套数组的所有元素来支持。如果嵌套数组的 dtype 为 object,则用户 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,用户需要定义在 用法和影响 部分概述并在整个提案中详细说明的方法和属性。

此外,类似于 PyArray_ArrFuncs 中的一些方法将用于下面的 slots 结构体。

NEP 41 中所述,在 C 中定义此 DType 类的接口以 PEP 384:Slots 为模型,并且一些其他信息将传递到 slots 结构体中并由 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 在方法解析顺序中排在第一位,这也可以防止对 == 等槽的不希望有的覆盖。

这些 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

如果可能,如果省略槽或将其设置为 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 中列出。上述相关部分也讨论了替代方案。

参考文献#