NEP 51 — 更改 NumPy 标量的表示形式#

作者:

Sebastian Berg

状态:

已接受

类型:

标准追踪

创建日期:

2022-09-13

决议:

https://mail.python.org/archives/list/numpy-discussion@python.org/message/U2A4RCJSXMK7GG23MA5QMRG4KQYFMO2S/

摘要#

NumPy 具有标量对象(“NumPy 标量”),代表与 NumPy DType 对应的单个值。这些标量的表示形式目前与 Python 内置类型匹配,例如:

>>> np.float32(3.0)
3.0

在本 NEP 中,我们建议更改其表示形式,以包含 NumPy 标量类型信息。将上述示例更改为:

>>> np.float32(3.0)
np.float32(3.0)

我们预计这一更改将帮助用户区分 NumPy 标量与 Python 内置类型,并澄清它们的行为。

一旦 NEP 50 被采纳,NumPy 标量与 Python 内置类型之间的区别将对用户而言变得更加重要。

这些更改确实会导致与数组打印相关的较小的不兼容和基础设施更改。

动机与范围#

本 NEP 建议更改以下 NumPy 标量类型的表示形式,以将其与 Python 标量区分开来:

  • np.bool_

  • np.uint8np.int8 以及所有其他整数标量

  • np.float16np.float32np.float64np.longdouble

  • np.complex64np.complex128np.clongdouble

  • np.str_np.bytes_

  • np.void(结构化 DType)

此外,其余 NumPy 标量的表示形式将被调整为打印为 np. 而不是 numpy.

  • np.datetime64np.timedelta64

  • np.void(非结构化版本)

本 NEP 不建议更改这些标量的打印方式——只更改它们的表示形式(__repr__)。此外,数组的表示形式将不受影响,因为它在必要时已经包含了 dtype=

这一更改的主要动机是 Python 数值类型的行为与 NumPy 标量不同。例如,低精度数字(如 uint8float16)应谨慎使用,用户在使用时应有所了解。所有 NumPy 整数都可能发生溢出,而 Python 整数则不会。在采纳 NEP 50 后,这些差异将更加突出,因为低精度的 NumPy 标量将更频繁地被保留。即使是 np.float64,它与 Python 的 float 非常相似并继承自它,但在除以零时其行为也不同。

另一个常见的混淆来源是 NumPy 布尔值。Python 程序员有时会写 obj is True,当一个显示为 True 的对象未能通过测试时会感到惊讶。当值显示为 np.True_ 时,这种行为更容易理解。

我们不仅期望这一改变能帮助用户更好地理解并记住 NumPy 标量和 Python 标量之间的差异,而且我们还相信这种意识将极大地帮助调试。

用法与影响#

大多数用户代码不应受此更改的影响,但用户现在将经常看到 NumPy 值显示为:

np.True_
np.float64(3.0)
np.int64(34)

等等。这也意味着 Jupyter notebook 单元格中的文档和输出将经常完整地显示类型信息。

np.longdoublenp.clongdouble 将使用单引号打印

np.longdouble('3.0')

以允许往返转换。除了这一更改之外,float128 现在将始终打印为 longdouble,因为旧名称会给人一种错误的精度印象。

向后兼容性#

我们预计大多数工作流程不会受到影响,因为只有打印方式发生变化。总的来说,我们认为告知用户他们正在使用的类型比在某些情况下调整打印方式的需求更为重要。

NumPy 测试套件中包含诸如 decimal.Decimal(repr(scalar)) 这样的代码。这段代码需要修改为使用 str()

一个例外是拥有文档,特别是文档测试的下游库。由于许多值的表示形式将发生变化,在许多情况下,文档必须更新。预计这在中期将需要更大的文档修复工作。

可能需要采用 doctest 测试工具,以允许对新表示形式进行近似值检查。

arr.tofile() 的更改#

在文本模式下,arr.tofile() 当前将值存储为 repr(arr.item())。这并不总是理想的,因为它可能包含转换为 Python 类型的操作。一个问题是,这将开始将 longdouble 保存为 np.longdouble('3.1'),这显然不是我们想要的。我们预计此方法很少用于对象数组。对于字符串数组,使用 repr 也会导致存储 "string"b"string",这似乎很少需要。

提案是将默认(改回)使用 str 而不是 repr。如果需要 repr,用户必须传入 fmt=%r

详细描述#

本 NEP 建议将 NumPy 标量的表示形式更改为:

  • 布尔值(其单例实例)的 np.True_np.False_

  • 所有数值 DType 的 np.scalar(<value>),即 np.float64(3.0)

  • np.longdoublenp.clongdouble 的值将加引号表示:np.longdouble('3.0')。这确保了它始终可以正确往返转换,并与 decimal.Decimal 的行为方式匹配。对于这两种类型,将不使用基于大小的名称,例如 float128,因为实际大小是平台相关的,因此具有误导性。

  • 字符串 DType 的 np.str_("string")np.bytes_(b"byte_string")

  • 结构化类型的 np.void((3, 5), dtype=[('a', '<i8'), ('b', 'u1')])(类似于数组)。这将是重新创建标量的有效语法。

与数组不同,标量表示应正确往返转换,因此 longdouble 值将加引号,其他值永不截断。

在某些地方(即掩码数组、void 和记录标量),我们将希望打印不带类型信息的表示形式。例如:

np.void(('3.0',), dtype=[('a', 'f16')])  # longdouble

应打印带引号的 3.0(以确保往返转换),但不要重复完整的 np.longdouble('3.0'),因为 DType 已包含 longdouble 信息。为了实现这一点,将引入一个新的半公共 np.core.array_print.get_formatter() 来扩展现有功能(参见“实施”章节)。

对掩码数组和记录的影响#

NumPy 的其他一些部分将间接受到影响。当数组的 DType 不匹配时,掩码数组的 fill_value 将被调整为仅包含完整的标量信息,例如 fill_value=np.float64(1e20)。对于 longdouble(DType 匹配),它将打印为 fill_value='3.1',包含引号,这(原则上但实践中可能不会)确保往返转换。应该指出,对于字符串,DType 在字符串长度上不匹配是常见的。因此,字符串通常将打印为 np.str_("N/A")

np.record 标量将与 np.void 对齐,并打印为完全相同(除了名称本身)。例如:np.record((3, 5), dtype=[('a', '<i8'), ('b', 'u1')])

关于 longdoubleclongdouble 的详细信息#

对于 longdoubleclongdouble 值,例如

np.sqrt(np.longdouble(2.))

除非加引号作为字符串(因为转换为 Python 浮点数会损失精度),否则可能无法往返转换。本 NEP 建议使用与 Python 的 Decimal 类似的单引号,其打印形式为 Decimal('3.0')

longdouble 可以有不同的精度和存储大小,从 8 到 16 字节不等。然而,即使 float128 是正确的,因为数字以 128 位存储,但它通常不具有 128 位精度。(clongdouble 也是如此,但存储大小是其两倍。)

因此,本 NEP 提出将 longdouble 的名称始终打印为 longdouble,而不是 float128float96。它不包括弃用 np.float128 别名。然而,这种弃用可能独立于本 NEP 发生。

整数标量类型名称和实例表示#

一个细节是,由于 NumPy 标量类型基于 C 类型,NumPy 有时会区分它们,例如在大多数 64 位系统上(非 Windows):

>>> np.longlong
numpy.longlong
>>> np.longlong(3)
np.int64(3)

该提案将导致类型使用 longlong 名称,而标量使用 int64 形式。做出此选择是因为 int64 通常对用户来说是更有用的信息,但类型名称本身必须精确。

实施#

注意

此部分尚未初始 PR 中实现。类似的更改将需要修复打印中的某些情况,并允许完全正确地打印例如包含 longdouble 的结构化标量。未来预计也需要类似的解决方案,以允许自定义 DType 正确打印。

新的表示形式主要可以在标量类型上实现,其中最大的更改需要在测试套件中完成。

对 void 标量和掩码 fill_value 的提议更改使得有必要公开不带类型信息的标量表示形式。

我们建议引入半公共 API:

np.core.arrayprint.get_formatter(*,
        data=None, dtype=None, fmt=None, options=None)

以替换当前内部的 _get_formatting_func。与旧函数相比,这将允许两件事:

  • data 可以为 None(如果传入了 dtype),从而无需传入多个稍后将打印/格式化的值。

  • fmt= 将允许未来将格式字符串传递给特定于 DType 的元素格式化程序。目前,get_formatter() 将接受 reprstr(单例而不是字符串)来格式化不带类型信息的元素(例如 '3.1' 而不是 np.longdouble('3.1'))。该实现确保格式匹配,除了类型信息之外。

    空格式字符串将与 str() 打印相同(当传入数据时可能带有额外的填充)。

预计 get_formatter() 将在未来查询用户 DType 的方法,从而允许为所有 DType 进行自定义格式化。

get_formatter 公开允许其用于 np.record 和掩码数组。目前,格式化程序本身似乎是半公共的;使用单个入口点有望为格式化 NumPy 值提供清晰的 API。

标量表示形式更改的大部分工作此前已由 Ganesh Kathiresan 在[2]中完成。

替代方案#

可以考虑不同的表示形式:替代方案包括将 np. 写成 numpy.,或者从数值标量中去掉 np. 部分。我们认为使用 np. 足够清晰、简洁,并且允许复制粘贴表示形式。仅使用 float64(3.0) 而不带 np. 前缀更简洁,但可能存在 NumPy 依赖关系不完全明确且名称可能与其他库冲突的情况。

对于布尔值,一种替代方案是使用 np.bool_(True)bool_(True)。然而,NumPy 布尔标量是单例,并且提议的格式更为简洁。布尔值的替代方案也曾在此前的[1]中讨论过。

对于字符串标量,混淆通常不那么明显。推迟更改这些可能更合理。

非有限值#

该提案不允许直接复制粘贴 naninf 值。它们可以表示为 np.float64('nan')np.float64(np.nan)。这更简洁,并且 Python 也使用 naninf,而不是通过显示为 float('nan') 来允许复制粘贴。可以说,这在 NumPy 中是一个较小的补充,它将始终被打印出来。

get_formatter() 的替代方案#

当传入 fmt= 时,特别是本 NEP 中的主要用途是格式化为 reprstr。也可以使用 ufunc 或直接的格式化函数,而不是将其封装到依赖于为 DType 实例化格式化器类的 `get_formatter() 中。

本 NEP 并不排除创建 ufunc 或开辟特殊路径。然而,NumPy 数组格式化通常会查看所有要格式化的值,以便添加对齐的填充或给出统一的指数输出。在这种情况下,会传入 data= 并用于准备。不幸的是,这种格式化形式(与标量情况下希望 data=None 的情况不同)与 UFuncs 根本不兼容。

使用单例 strrepr 确保未来的格式化字符串,如 f"{arr:r}",不会因使用 "r""s" 而受到任何限制。

讨论#

参考文献和脚注#