NEP 50 — Python 标量类型的促销规则#
- 作者:
Sebastian Berg
- 状态:
最终
- 类型:
标准轨道
- 创建时间:
2021-05-25
摘要#
自 NumPy 1.7 起,促销规则使用所谓的“安全转换”,该规则依赖于对涉及值的检查。这有助于识别用户面临的许多边缘情况,但实现起来很复杂,而且行为也难以预测。
有两种令人困惑的结果
基于值的促销意味着,例如 Python 整数的值可以由
np.result_type找到的输出类型决定。np.result_type(np.int8, 1) == np.int8 np.result_type(np.int8, 255) == np.int16
此逻辑的出现是因为
1可以由uint8或int8表示,而255无法由int8表示,而只能由uint8或int16表示。这在处理 0-D 数组(所谓的“标量数组”)时也适用。
int64_0d_array = np.array(1, dtype=np.int64) np.result_type(np.int8, int64_0d_array) == np.int8
其中
int64_0d_array具有int64数据类型的这一事实,对结果数据类型没有影响。在此示例中,dtype=np.int64被有效地忽略,因为只有其值很重要。对于 Python
int、float或complex,会像前面所示那样检查其值。但令人惊讶的是,当 NumPy 对象是 0-D 数组或 NumPy 标量时,则不会。np.result_type(np.array(1, dtype=np.uint8), 1) == np.int64 np.result_type(np.int8(1), 1) == np.int64
原因是,当所有对象都是标量或 0-D 数组时,会禁用基于值的促销。因此,NumPy 返回与
np.array(1)相同的类型,该类型通常是int64(这取决于系统)。
请注意,这些示例也适用于乘法、加法、比较等操作,以及相应的函数,如 np.multiply。
此 NEP 提议重构行为,遵循两个指导原则
值绝不能影响结果类型。
NumPy 标量和 0-D 数组的行为应与其 N-D 对应项保持一致。
我们提议删除所有基于值的逻辑,并为 Python 标量添加特殊处理以保留一些方便的行为。Python 标量将被视为“弱”类型。当 NumPy 数组/标量与 Python 标量结合时,它将被转换为 NumPy 数据类型,以便
np.array([1, 2, 3], dtype=np.uint8) + 1 # returns a uint8 array
np.array([1, 2, 3], dtype=np.float32) + 2. # returns a float32 array
不会依赖 Python 值本身。
拟议的更改也适用于 np.can_cast(100, np.int8),但是,我们预计函数中的行为(促销)在实践中将远比转换本身更重要。
注意
截至 NumPy 1.24.x 系列,NumPy 已提供初步且有限的支持来测试此提案。
此外,还需要设置以下环境变量
export NPY_PROMOTION_STATE=weak
有效值为 weak、weak_and_warn 和 legacy。请注意,weak_and_warn 实现此 NEP 中提议的可选警告,并且预计会非常嘈杂。我们建议从使用 weak 选项开始,并主要使用 weak_and_warn 来理解特定观察到的行为变化。
以下附加 API 存在
np._set_promotion_state()和np._get_promotion_state(),它们等同于环境变量。(非线程/上下文安全。)with np._no_nep50_warning():允许在weak_and_warn促销使用时抑制警告。(线程和上下文安全。)
此时,整数幂的溢出警告缺失。此外,np.can_cast 在 weak_and_warn 模式下无法发出警告。其关于 Python 标量输入的行为可能仍在变化中(这只会影响极少数用户)。
新促销规则的架构#
更改后,NumPy 中的促销将遵循以下架构。促销始终沿着绿色线条进行:在其种类内从左到右,仅在必要时才升至更高的种类。结果种类始终是输入的最高种类。请注意,float32 的精度低于 int32 或 uint32,因此在示意图中略微向左排序。这是因为 float32 无法精确表示所有 int32 值。但是,出于实际原因,NumPy 允许将 int64 促销到 float64,有效地认为它们具有相同的精度。
Python 标量插入到每个“种类”的最左边,Python 整数不区分有符号和无符号。因此,NumPy 促销使用以下有序的种类类别
布尔值
整数:有符号或无符号整数
非精确:浮点数和复数浮点数
在促销具有较低种类类别(布尔值 < 整数 < 非精确)的 Python 标量与具有较高种类的标量时,我们使用最小/默认精度:即 float64、complex128 或 int64(int32 在某些系统上使用,例如 Windows)。
请参阅下一节,其中提供了澄清拟议行为的示例。下面的表格中还可以找到与当前行为的比较示例。
新行为示例#
为了更容易理解上述文本和图示,我们提供一些新行为的示例。在下面,Python 整数对结果类型没有影响
np.uint8(1) + 1 == np.uint8(2)
np.int16(2) + 2 == np.int16(4)
在以下示例中,Python float 和 complex 是“非精确”的,但 NumPy 值是整数,因此我们至少使用 float64/complex128
np.uint16(3) + 3.0 == np.float64(6.0)
np.int16(4) + 4j == np.complex128(4+4j)
但这不会发生在 float 到 complex 的促销中,其中 float32 和 complex64 具有相同的精度。
np.float32(5) + 5j == np.complex64(5+5j)
请注意,示意图省略了 bool。它设置在“整数”下方,因此以下规定成立
np.bool_(True) + 1 == np.int64(2)
True + np.uint8(2) == np.uint8(3)
请注意,虽然此 NEP 使用简单的运算符作为示例,但所述规则通常适用于所有 NumPy 操作。
比较新旧行为的表格#
下表列出了相关的更改和未更改的行为。请参阅 旧实现 以详细解释导致“旧结果”的规则,以及以下各节以了解详细说明新行为的规则。向后兼容性部分讨论了这些更改可能如何影响用户。
请注意 0-D 数组(如 array(2))与非 0-D 数组(如 array([2]))之间的重要区别。
表达式 |
旧结果 |
新结果 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
未更改 |
|
|
未更改 |
|
|
未更改 [T3] |
|
|
异常 [T4] |
|
|
异常 [T5] |
|
|
|
|
|
|
|
|
未更改 |
|
|
|
|
|
未更改 |
|
|
|
|
|
|
|
|
|
|
|
未更改 [T12] |
新行为尊重 uint8 标量的数据类型。
当前的 NumPy 会在与数组组合时忽略 0-D 数组或 NumPy 标量的精度。
当前的 NumPy 会在与数组组合时忽略 0-D 数组或 NumPy 标量的精度。
旧行为使用 uint16,因为 300 不适合 uint8,新行为因同样原因引发错误。
300 无法转换为 uint8。
可能是最危险的更改之一。保留类型会导致溢出。为 NumPy 标量给出指示溢出的 RuntimeWarning。
np.float32(3e100) 溢出为无穷大并发出警告。
1 + 1e-14 在 float32 中执行时会丢失精度,但在 float64 中不会。旧的行为是根据数组的维度以不同的方式将标量参数转换为 float32 或 float64;使用新行为,计算始终以数组精度(在此例中为 float32)执行。
NumPy 将 float32 和 int64 促销到 float64。旧行为在此忽略了 int64。
新行为在 array(3, complex64) 和 array([3], complex64) 之间是一致的:结果的数据类型是数组参数的数据类型。
新行为使用与数组参数(float32)精度兼容的复数数据类型。
由于数组种类是整数,结果使用默认的复数精度,即 complex128。
动机与范围#
更改关于检查 Python 标量和 NumPy 标量/0-D 数组值的行为的动机如下
对 NumPy 标量/0-D 数组的特殊处理以及值检查可能会让用户感到非常惊讶,
值检查逻辑更难解释和实现。此外,通过 NEP 42 向用户定义的数据类型提供此功能也更困难。目前,这导致了新系统和旧系统(值敏感)的双重实现。解决这个问题将大大简化内部逻辑,并使结果更加一致。
它在很大程度上与 JAX 和 data-apis.org 等其他项目的选择保持一致(另请参阅 相关工作)。
我们相信,“弱”Python 标量的提议将通过提供清晰的心智模型来帮助用户理解操作将产生什么样的数据类型。此模型与 NumPy 目前经常遵循的数组精度保持一致,并用于就地操作。
arr += value
只要不跨越“种类”边界(否则将引发错误),就保留精度。
虽然一些用户可能会怀念值检查行为,但即使在它看起来很有用的情况下,它也会很快导致令人惊讶的结果。这可能是预料之中的
np.array([100], dtype=np.uint8) + 1000 == np.array([1100], dtype=np.uint16)
但以下情况将是令人惊讶的
np.array([100], dtype=np.uint8) + 200 == np.array([44], dtype=np.uint8)
考虑到该提案与就地操作数的行为一致,并避免了有时仅能避免结果溢出的令人惊讶的行为变化,我们相信该提案遵循“最小惊讶原则”。
用法与影响#
此 NEP 预计实现时没有过渡期,不会对所有更改发出警告。这样的过渡期会产生许多(通常是无害的)警告,这些警告很难消除。我们预计大多数用户将从更清晰的促销规则中长期受益,并且很少有人直接(负面)受到更改的影响。但是,某些使用模式可能会导致问题性更改,这些在向后兼容性部分有详细说明。
解决方案将是一个可选警告模式,能够通知用户潜在的行为变化。此模式预计会产生许多无害的警告,但提供了在观察到问题时系统地审查代码和跟踪更改的方法。
对 can_cast 的影响#
can_cast 将永远不再检查值。因此,以下结果预计将从 True 更改为 False
np.can_cast(np.int64(100), np.uint8)
np.can_cast(np.array(100, dtype=np.int64), np.uint8)
np.can_cast(100, np.uint8)
我们预计此更改的影响将小于以下更改的影响。
注意
最后一个输入为 Python 标量的示例可能会被保留,因为 100 可以由 uint8 表示。
涉及 NumPy 数组或标量的运算符和函数的影响#
对不涉及 Python 标量(float、int、complex)的操作的主要影响是,对 0-D 数组和 NumPy 标量的操作将永远不会取决于它们的值。这消除了目前令人惊讶的情况。例如
np.arange(10, dtype=np.uint8) + np.int64(1)
# and:
np.add(np.arange(10, dtype=np.uint8), np.int64(1))
将来会返回一个 int64 数组,因为 np.int64(1) 的类型会被严格遵守。目前返回一个 uint8 数组。
涉及 Python int、float 和 complex 的运算符的影响#
此 NEP 试图在处理文字值时保留旧行为的便利性。当前基于值的逻辑在涉及“未类型化”的文字 Python 标量时具有一些不错的属性。
np.arange(10, dtype=np.int8) + 1 # returns an int8 array
np.array([1., 2.], dtype=np.float32) * 3.5 # returns a float32 array
但在涉及“不可表示”值时会导致意外。
np.arange(10, dtype=np.int8) + 256 # returns int16
np.array([1., 2.], dtype=np.float32) * 1e200 # returns float64
提议在很大程度上保留此行为。这是通过将 Python int、float 和 complex 视为操作中的“弱”类型来实现的。但是,为了避免意外,我们计划使转换为新类型更加严格:前两个示例的结果将保持不变,但在第二个示例中,它将更改如下。
np.arange(10, dtype=np.int8) + 256 # raises a TypeError
np.array([1., 2.], dtype=np.float32) * 1e200 # warning and returns infinity
第二个示例发出警告是因为 np.float32(1e200) 溢出为无穷大。然后它将继续使用 inf 进行计算,就像平常一样。
其他库中的行为
在转换时溢出而不是引发错误是一种选择;这是大多数 C 设置中的默认行为(尽管 NumPy 也可以被配置为因溢出而引发错误)。它也是例如 pytorch 1.10 的行为。
Python 整数的特定行为#
NEP 中描述的促销规则是基于结果数据类型的,结果数据类型通常也是操作数据类型(就结果精度而言)。这会导致 Python 整数出现一些看似异常的情况:虽然 uint8(3) + 1000 必须被拒绝,因为在 uint8 中进行操作是不可能的,但 uint8(3) / 1000 返回 float64,并且可以将两个输入转换为 float64 以找到结果。
实际上,这意味着在以下情况下接受任意 Python 整数值
NumPy 和 Python 整数之间的所有比较(
==、<等)始终是明确定义的。产生浮点结果的一元函数(如
np.sqrt)可以并且将会把 Python 整数转换为浮点数。整数除法通过将输入转换为
float64来返回浮点数。
请注意,可能还有其他函数可以应用这些例外情况但未应用。在这些情况下,应将其视为改进,但当用户影响很小时,我们可能会为了简单起见而不这样做。
向后兼容性#
总的来说,仅使用默认数据类型 float64、int32/int64 或更精确的数据类型的代码应该不受影响。
然而,拟议的更改将修改许多混合了 0-D 或标量值(具有非默认数据类型)的情况下的结果。在许多情况下,这些将是错误修复,但存在某些更改可能对最终用户产生问题。
最重要的潜在故障可能是以下示例
arr = np.arange(100, dtype=np.uint8) # storage array with low precision
value = arr[10]
# calculation continues with "value" without considering where it came from
value * 100
其中之前的 value * 100 会导致向上转换为 int32/int64(因为 value 是一个标量)。新行为将保留较低的精度,除非明确处理(就像 value 是一个数组一样)。这可能导致整数溢出,从而导致精度以外的不正确结果。在许多情况下,这可能是无声的,尽管 NumPy 通常会为标量运算符发出警告。
同样,如果存储数组是 float32,则计算可能会保留较低的 float32 精度,而不是使用默认的 float64。
还会出现其他问题。例如
浮点数比较,尤其是相等性,在混合精度时可能会发生变化。
np.float32(1/3) == 1/3 # was False, will be True.
预计某些操作将开始失败
np.array([1], np.uint8) * 1000 np.array([1], np.uint8) == 1000 # possibly also
以保护用户免受先前基于值的转换导致向上转换的情况。(在将
1000转换为uint8时发生失败。)浮点数溢出可能会在更奇怪的情况下发生
np.float32(1e-30) * 1e50 # will return ``inf`` and a warning
因为
np.float32(1e50)返回inf。以前,即使1e50不是 0-D 数组,它也会返回双精度结果。
在其他情况下,可能会出现更高的精度。例如
np.multiple(float32_arr, 2.)
float32_arr * np.float64(2.)
都将返回 float64 而不是 float32。这提高了精度,但略微改变了结果并使用了双倍的内存。
因整数“精度阶梯”而产生的变化#
从 Python 整数创建数组时,NumPy 将按顺序尝试以下类型,结果取决于值
long (usually int64) → int64 → uint64 -> object
这与上面描述的促销有细微差别。
此 NEP 目前不包括更改此阶梯(尽管可以在单独的文档中建议)。但是,在混合操作中,此阶梯将被忽略,因为值将被忽略。这意味着操作将永远不会隐式使用 object 数据类型。
np.array([3]) + 2**100 # Will error
用户必须编写以下任一
np.array([3]) + np.array(2**100)
np.array([3]) + np.array(2**100, dtype=np.object_)
因此,隐式转换为 object_ 应该很少见,并且解决方法很清楚,我们预计向后兼容性问题非常小。
详细描述#
以下提供了有关当前“基于值”的促销逻辑的更多详细信息,然后是关于“弱标量”促销及其内部处理方式的详细信息。
旧的“基于值”促销实现#
本节回顾了当前基于值的逻辑在实践中是如何工作的,请参阅下一节了解其有用示例。
当 NumPy 遇到“标量”值时,该值可以是 Python int、float、complex、NumPy 标量或数组
1000 # Python scalar
int32(1000) # NumPy scalar
np.array(1000, dtype=int64) # zero dimensional
或者浮点/复数等价物,NumPy 将忽略数据类型的精度,并找到能够容纳该值的所有可能的最小数据类型。也就是说,它将尝试以下数据类型
整数:
uint8、int8、uint16、int16、uint32、int32、uint64、int64。浮点:
float16、float32、float64、longdouble。复数:
complex64、complex128、clongdouble。
请注意,例如对于整数值 10,最小的数据类型可以是要么 uint8 要么 int8。
当所有参数都是标量值时,NumPy 从不应用此规则。
np.int64(1) + np.int32(2) == np.int64(3)
对于整数,值是否适合取决于它是否可以由数据类型表示。对于浮点数和复数,如果满足以下条件,则认为数据类型足够
float16:-65000 < value < 65000(或 NaN/Inf)float32:-3.4e38 < value < 3.4e38(或 NaN/Inf)float64:-1.7e308 < value < 1.7e308(或 Nan/Inf)longdouble:(范围最大,无限制)
对于复数,这些界限应用于实部和虚部。这些值大致对应于 np.finfo(np.float32).max。(NumPy 从未强制使用 float64 来表示 float32(3.402e38) 的值,但会为 3.402e38 的 Python 值这样做。)
当前“基于值”促销的状态#
在我们能够提出当前数据类型系统的替代方案之前,回顾一下“基于值”的促销是如何使用的以及它可能带来的好处是很有帮助的。基于值的促销允许以下代码工作
# Create uint8 array, as this is sufficient:
uint8_arr = np.array([1, 2, 3], dtype=np.uint8)
result = uint8_arr + 4
result.dtype == np.uint8
result = uint8_arr * (-1)
result.dtype == np.int16 # upcast as little as possible.
其中第一部分尤其有用:用户知道输入是一个具有特定精度的整数数组。考虑到纯粹的 + 4 保留前一个数据类型是符合直觉的。将此示例替换为 np.float32 也许更清晰,因为浮点数很少会溢出。如果没有这种行为,上面的示例将需要编写 np.uint8(4),而缺乏这种行为将使以下内容令人惊讶
result = np.array([1, 2, 3], dtype=np.float32) * 2.
result.dtype == np.float32
缺乏特殊情况将导致返回 float64。
需要注意的是,此行为也适用于通用函数和零维数组。
# This logic is also used for ufuncs:
np.add(uint8_arr, 4).dtype == np.uint8
# And even if the other array is explicitly typed:
np.add(uint8_arr, np.array(4, dtype=np.int64)).dtype == np.uint8
回顾一下,如果我们用 [4] 替换 4 使其成为一维,结果将不同。
# This logic is also used for ufuncs:
np.add(uint8_arr, [4]).dtype == np.int64 # platform dependent
# And even if the other array is explicitly typed:
np.add(uint8_arr, np.array([4], dtype=np.int64)).dtype == np.int64
提议的弱提升#
此提案使用了“弱标量”逻辑。这意味着 Python 的 int、float 和 complex 不会被分配典型的 DType,例如 float64 或 int64。相反,它们会被分配一个特殊的抽象 DType,类似于“标量”层次结构名称:Integral、Floating、ComplexFloating。
当发生提升时(例如,当 ufuncs 没有找到精确的循环匹配时),另一个 DType 能够决定如何看待 Python 标量。例如,UInt16 与 Integral 提升将得到 UInt16。
注意
将来很可能会为用户定义的 DType 提供默认值。最有可能的情况是这将是默认的整数/浮点数,但原则上可以实现更复杂的方案。
在任何时候,值都不会用于决定此提升的结果。仅当值转换为新的 dtype 时才会考虑该值;这可能会引发错误。
实现#
实现此 NEP 需要向所有二元运算符(或 ufuncs)添加一些额外的机制,以便它们在可能的情况下尝试使用“弱”逻辑。有两种可能的方法:
当出现这种情况时,二元运算符会尝试调用
np.result_type()并将 Python 标量转换为结果类型(如果已定义)。二元运算符指示输入是 Python 标量,其余的由 ufunc 分派/提升机制处理(参见 NEP 42)。这提供了更大的灵活性,但需要在 ufunc 机制中添加一些额外的逻辑。
注意
目前尚不清楚哪种方法更好,两种方法都会产生相当等价的结果,如果需要,可以在未来用 2. 扩展 1.。
它还需要移除所有当前基于特殊值的代码路径。
不符合直觉的是,实现中的一个更大步骤可能是实现一个解决方案,允许在以下示例中引发错误:
np.arange(10, dtype=np.uint8) + 1000
尽管 np.uint8(1000) 返回的值与 np.uint8(232) 相同。
注意
请参阅替代方案,我们可能仍然认为这种静默溢出是可以接受的,或者至少是另一个问题。
替代方案#
在几个设计维度上可能存在不同的选择。以下章节将概述这些。
使用强类型标量或两者的混合#
解决基于值提升/强制转换问题最简单的解决方案是使用强类型 Python 标量,即 Python 浮点数被视为双精度,Python 整数始终被视为与默认整数 dtype 相同。
这将是最简单的解决方案,但是,它会在处理 float32 或 int16 等数组时导致许多向上转换。这些情况的解决方案是依赖就地操作。我们目前认为,尽管危险性较小,但此更改会影响许多用户,并且比不影响用户更令人惊讶(尽管期望差异很大)。
原则上,弱行为与强行为不必是统一的。也可以让 Python 浮点数使用弱行为,但 Python 整数使用强行为,因为整数溢出更令人惊讶。
不在函数中使用弱标量逻辑#
此 NEP 提案的一个替代方案是将弱类型的用途限制在 Python 运算符上。
这有利有弊
主要优点是,将其限制为 Python 运算符意味着这些“弱”类型/DType 对短 Python 语句而言是明显短暂的。
缺点是
np.multiply和*的可互换性较差。仅将“弱”提升用于运算符意味着库不必担心它们是否希望“记住”输入最初是 Python 标量。另一方面,它将需要为 Python 运算符添加稍微不同(或附加)的逻辑。(技术上,可能是作为 ufunc 分派机制的一个标志来切换弱逻辑。)
__array_ufunc__通常单独用于为实现它的类数组提供 Python 运算符支持。如果运算符是特殊的,这些类数组可能需要一种机制来匹配 NumPy(例如,ufunc 的一个关键字参数来启用弱提升)。
NumPy 标量可能是特殊的#
许多用户期望 NumPy 标量应与 NumPy 数组不同,即 np.uint8(3) + 3 应返回 int64(或 Python 整数),而 uint8_arr + 3 会保留 uint8 DType。
此替代方案将非常接近 NumPy 标量当前的(行为),但它将固化数组和标量之间的区别(NumPy 数组比 Python 标量“强”,但 NumPy 标量不是)。
这种区别是很有可能的,然而,目前 NumPy 经常(并静默地)将 0 维数组转换为标量。因此,仅当我们也更改这种静默转换(有时称为“衰减”)行为时,考虑此替代方案才是有意义的。
处理不安全标量转换#
像这样的情况
np.arange(10, dtype=np.uint8) + 1000
应根据此 NEP 引发错误。这可以放宽为给出警告,甚至忽略“不安全”转换(在所有相关硬件上,这将导致使用 np.uint8(1000) == np.uint8(232))。
允许弱类型数组#
具有弱类型 Python 标量但不是弱类型数组的一个问题是,在许多情况下 np.asarray() 会不加区分地应用于输入。为了解决这个问题,JAX 会将 np.asarray(1) 的结果也视为弱类型。然而,这有两个困难:
JAX 注意到以下情况可能令人困惑:
np.broadcast_to(np.asarray(1), (100, 100))
是一个“继承”弱类型的非 0 维数组。[2]
与 JAX 张量不同,NumPy 数组是可变的,因此赋值是否需要使其强类型?
作为一个实现细节(例如,在 ufuncs 中),一个标志可能会很有用,但是,到目前为止,我们不希望将其作为用户 API。主要原因是如果这样的标志作为函数的结果传递出去,而不是仅在非常局部的地方使用,它可能会让用户感到惊讶。
待办事项
在接受 NEP 之前,可能需要进一步讨论这个问题。库可能需要更清晰的模式来“传播”“弱”类型,这可能只是一个 np.asarray_or_literal() 来保留 Python 标量,或者在调用 np.asarray() 之前调用 np.result_type() 的模式。
继续为 Python 标量使用基于值逻辑#
当前逻辑的一些主要问题源于我们将它应用于 NumPy 标量和 0 维数组,而不是应用于 Python 标量。因此,我们可以考虑继续检查 Python 标量的值。
我们以不消除前面提到的意外情况为由拒绝此想法。
np.uint8(100) + 1000 == np.uint16(1100)
np.uint8(100) + 200 == np.uint8(44)
根据结果值而不是输入值调整精度可能适用于标量操作,但对于数组操作则不可行。这是因为数组操作需要在执行计算之前分配结果数组。
讨论#
参考文献和脚注#
版权#
本文档已置于公共领域。[1]