NEP 18 — NumPy 高级数组函数的分发机制#

作者:

史蒂芬·霍耶 <shoyer@google.com>

作者:

马修·洛克林 <mrocklin@gmail.com>

作者:

马滕·凡·科克维克 <mhvk@astro.utoronto.ca>

作者:

哈米尔·阿巴斯 <hameerabbasi@yahoo.com>

作者:

埃里克·维泽 <wieser.eric@gmail.com>

状态:

最终版

类型:

标准轨道

创建:

2018-05-29

更新:

2019-05-25

解决方案:

https://mail.python.org/pipermail/numpy-discussion/2018-August/078493.html

摘要#

我们提出 __array_function__ 协议,以允许 NumPy 函数的参数定义此函数如何对它们进行操作。即使在数组实现与 numpy.ndarray 存在很大差异时,此函数也可以使用 NumPy 作为高效多维数组操作的高级 API。

详细描述#

NumPy 高级 ndarray API 已在 NumPy 本身之外针对不同架构(例如 GPU 数组 (CuPy)、稀疏数组 (scipy.sparse、pydata/sparse) 和并行数组 (Dask 数组) 以及深度学习框架中的各种类似 NumPy 的实现(如 TensorFlow 和 PyTorch)实现了多次。

类似地,有很多项目构建在 NumPy 的带标签数组和索引数组的 API (XArray),自动微分(Autograd、Tangent),掩码数组(numpy.ma),物理单位(astropy.units、pint、unyt)等之上,它们在 NumPy API 之上添加额外的功能。其中大多数项目也实现了对 NumPy 高级 API 的接近变体实现。

我们希望能够将这些库一起使用,例如我们希望能够将一个 CuPy 数组放在 XArray 中,或对 Dask 数组代码执行自动微分。如果针对 NumPy ndarrays 编写的代码也可以在其他 NumPy 类似项目中使用,那么完成起来会更容易。

例如,我们希望以下代码示例对任何 NumPy 类似数组对象正常工作

def f(x):
    y = np.tensordot(x, x.T)
    return np.mean(np.exp(y))

通过 NumPy 中的各种协议机制,其中一些今天已经成为可能。

  • np.exp 函数检查 __array_ufunc__ 协议

  • .T 方法使用 Python 的方法分派

  • np.mean 函数明确地检查参数上的 .mean 方法

但是,其他函数诸如 np.tensordot 不会分派,而是可能强制转换为一个 NumPy 数组(使用 __array__ 协议),或者直接错误。为了实现对 NumPy API 的充分覆盖以支持 XArray 和 autograd 这样的下游项目,我们希望支持 NumPy 中的几乎所有函数,这要求一种比 __array_ufunc__ 协议更大范围的协议。我们希望一个允许 NumPy 函数的参数控制并执行转向另一个函数(例如 GPU 或并行化实现)的协议在各个项目之间以一种安全且一致的方式执行此操作。

实施#

我们建议在 NumPy 中添加对新协议 __array_function__ 的支持。

该协议旨在成为 __array_ufunc__ 协议(对于 np.exp 这样的通用函数)无法覆盖的 NumPy 功能的总称。语义与 __array_ufunc__ 协议非常相似,除了操作是由任意的 callable 对象而不是 ufunc 实例和方法指定的。

可以在 此 notebook 中找到原型实现。

警告

__array_function__ 协议以及对其在特定功能上的使用是实验性的。我们计划保留一个可用于覆盖 NumPy 函数的接口,但这么做的方式可以改变而且会在不提供很多预警的情况下改变。如果不接受此类降低向后兼容性保证,请不要依赖于非 NumPy 数组的 NumPy 函数覆盖。有关更多详细信息,请参见下文的“非目标”。

注意

已实现使用 __array_function__ 协议进行调度,但尚未默认启用

  • 在 NumPy 1.16 中,需要在导入 NumPy 之前设置环境变量 NUMPY_EXPERIMENTAL_ARRAY_FUNCTION=1,以测试 NumPy 函数覆盖。

  • 在 NumPy 1.17 中,协议将默认启用,但可以使用 NUMPY_EXPERIMENTAL_ARRAY_FUNCTION=0 禁用。

  • 最终,希望 __array_function__ 始终启用。

接口#

我们为 __array_function__ 的实现提出以下签名

def __array_function__(self, func, types, args, kwargs)
  • func 是 NumPy 公共 API 暴露的任意可调用对象,以 func(*args, **kwargs) 的形式进行调用。

  • types 是来自原始 NumPy 函数调用且实现了 __array_function__ 的唯一参数类型的集合

  • 元组 args 和字典 kwargs 直接从原始调用中传递过来。

__array_ufunc__ 不同,这里没有关于 func 的类型或关于哪些 argskwargs 可能包含实现数组 API 的对象的高级保证。

作为对__array_function__实现者的便利,types为所有参数类型提供'__array_function__'属性。这允许实现者快速识别应递延到其他参数的__array_function__实现的情况。types的类型是故意模糊的:frozenset最接近预期用途,但我们可能出于性能原因改用tuple。无论如何,__array_function__实现不应依赖于types的迭代顺序,这将违反定义明确的“类型转换层次”(如NEP-13中所述)。

实现 NumPy API 的项目的示例#

大多数__array_function__实现将从两个检查开始

  1. 给定的函数是我们知道如何重载的东西吗?

  2. 参数是否都属于我们知道如何处理的类型?

如果这些条件成立,__array_function__应返回调用其对func(*args, **kwargs)的实现后的结果。否则,它应返回哨兵值NotImplemented,表示函数未按这些类型实现。这比直接引发TypeError更可取,因为它为其他参数提供了定义操作的机会。

__array_function__的返回值没有一般要求,但大多数明智的实现应该返回与其函数参数中一个类型相同的数组。如果/何时 Python 获得对协议的类型化支持,NumPy 添加静态类型注释,@overloadSupportsArrayFunction的实现将表示为Any的返回类型。

定义一个自定义装饰器(下面的implements)也可能方便用于注册__array_function__实现。

HANDLED_FUNCTIONS = {}

class MyArray:
    def __array_function__(self, func, types, args, kwargs):
        if func not in HANDLED_FUNCTIONS:
            return NotImplemented
        # Note: this allows subclasses that don't override
        # __array_function__ to handle MyArray objects
        if not all(issubclass(t, MyArray) for t in types):
            return NotImplemented
        return HANDLED_FUNCTIONS[func](*args, **kwargs)

def implements(numpy_function):
    """Register an __array_function__ implementation for MyArray objects."""
    def decorator(func):
        HANDLED_FUNCTIONS[numpy_function] = func
        return func
    return decorator

@implements(np.concatenate)
def concatenate(arrays, axis=0, out=None):
    ...  # implementation of concatenate for MyArray objects

@implements(np.broadcast_to)
def broadcast_to(array, shape):
    ...  # implementation of broadcast_to for MyArray objects

请注意,__array_function__ 的实现不需要包含 NumPy 对应函数的所有可选参数 (例如,上面的 broadcast_to 省略了无关的 subok 参数)。仅当 NumPy 函数调用中明确使用了可选参数时,它们才会传递到 __array_function__

注意

恰如 __add__ 等内置特殊方法的情况,编写正确的 __array_function__ 方法在遇到未知类型时应始终返回返回 NotImplemented。否则,如果运算也包含一个属于该对象的运算,则无法从另一个对象正确覆盖 NumPy 函数。

NumPy 代码库本身中必要的更改#

则需要在 NumPy 代码库中进行两项更改

  1. 一个检查可用输入的函数,查找这些输入上的 __array_function__ 属性,并适当地调用这些方法,直到其中一个成功。在通用的全 NumPy 情况下,此项操作必须快速,即使重载输入的数量很大(例如就像 np.concatenate 的情况),性能也可以接受(不低于线性时间)。

    这是一个具有中等复杂度的附加功能。

  2. 在所有相关的 NumPy 函数中调用此函数。

    这会影响 NumPy 代码库的许多部分,尽管复杂性很低。

查找并调用正确的 __array_function__#

给定一个 NumPy 函数,*args**kwargs 输入,我们需要在 *args**kwargs 中搜索可能有 __array_function__ 属性的所有适当输入。然后,我们需要在这些可能的方法中进行选择并执行正确的方法。在多个可能的实现之间进行协商可能很复杂。

查找参数#

有效参数可能直接位于 *args**kwargs 中,就如 np.tensordot(left, right, out=out) 的情况那样,或者可能嵌套在列表或字典中,就如 np.concatenate([x, y, z]) 的情况那样。这可能因以下两个原因造成麻烦

  1. 有些函数提供了很长的值列表,遍历这些值可能会非常昂贵。

  2. 有些函数可能有我们不想检查的参数,即使它们有 __array_function__ 方法。

为了解决这些问题,NumPy 函数应明确指出其哪些参数可能被重载,以及如何检查这些参数。作为一项规则,这应包括所有被记录为 array_likendarray 的参数。

我们建议通过为每个超载的 NumPy 函数编写“分发器”函数来实现此目的

  • 这些函数将使用传给 NumPy 函数的完全相同参数调用(即,dispatcher(*args, **kwargs)),并应该返回一个可迭代的参数,以检查是否进行了覆盖。

  • 分发器函数需要与其对应的 NumPy 函数共享完全相同的位置、可选和仅关键字参数。否则,在调用其分发器时,对 NumPy 函数的有效调用可能会导致错误。

  • 由于关键字参数的默认没有 __array_function__ 属性,所以按照惯例,我们将所有默认参数值设为 None。这降低了签名不同步的可能性,并将分发器中的无关信息最小化了。唯一的例外情况应该是参数值以某种方式影响分派的情况,这种情况应该很少见。

对于 np.concatenate 的分发器,一个示例可能具有指导意义

def _concatenate_dispatcher(arrays, axis=None, out=None):
    for array in arrays:
        yield array
    if out is not None:
        yield out

concatenate 分发器被写成一个生成器函数,这允许它可能包括可选 out 参数的值,而不需要创建一个新序列,其中包含要连接的对象(可能是很长的)列表。

尝试 __array_function__ 方法,直到找到正确的#

许多参数可能会实现 __array_function__ 协议。其中一些可能会根据给定的输入决定它们不能确定正确的结果。我们如何调用正确的?如果几个都有效,那么哪个有优先权?

在大多数情况下,使用 __array_function__ 进行调度的规则与使用 __array_ufunc__ 进行调度的规则相匹配(请参阅 NEP-13)。特别是

  • NumPy 将从所有指定输入收集 __array_function__ 的实现并按顺序调用它们:子类在超类之前,否则从左到右。请注意,在涉及子类的某些特殊情况下,这与 Python 的当前行为略有不同。

  • __array_function__ 的实现通过返回除 NotImplemented 之外的任何值表示它们可以处理该操作。

  • 如果所有 __array_function__ 方法都返回 NotImplemented,NumPy 将引发 TypeError

如果没有 __array_function__ 方法,NumPy 将默认调用其自己的实现,该实现旨在用于 NumPy 数组。例如,当所有类数组参数都是 Python 数字或列表时,就会出现这种情况。(NumPy 数组确实有 __array_function__ 方法,如下所示,但如果任何参数(NumPy 数组子类除外)实现了 __array_function__),它总是会返回 NotImplemented。)

__array_ufunc__ 当前行为的一个不同之处在于,NumPy 仅会对每个唯一类型的第一个参数调用 __array_function__。这符合 Python 的反射方法调用规则,并且确保了即使有大量重载参数,检查重载也具有可接受的性能。为了避免这两个调度协议之间的长期分歧,我们应该还更新 __array_ufunc__ 以匹配此行为。

numpy.ndarray 上的 __array_function__ 方法#

具有 __array_function__ 的子类的用例与具有 __array_ufunc__ 的子类的用例相同,因此 numpy.ndarray 也定义了一个 __array_function__ 方法。

def __array_function__(self, func, types, args, kwargs):
    if not all(issubclass(t, ndarray) for t in types):
        # Defer to any non-subclasses that implement __array_function__
        return NotImplemented

    # Use NumPy's private implementation without __array_function__
    # dispatching
    return func._implementation(*args, **kwargs)

此方法匹配 NumPy 的分发规则,因此在大多数情况下可以假装 ndarray.__array_function__ 不存在。在下方在 array_function_dispatch 装饰器中定义的、私有的 _implementation 属性使我们能够避免 __array_ufunc__ 协议中需要的、针对 NumPy 数组的特殊情况。

因此 __array_function__ 协议始终在超类之前调用子类,因此,如果任何 ndarray 子类参与操作,则它们将有机会覆盖它,就像任何其他参数覆盖 __array_function__ 一样。而操作(base NumPy 数组和子类一起)的默认行为不同:如果子类返回 NotImplemented,则会调用 NumPy 的函数实现,而不是引发异常。这是合适的,因为 预期子类是可以替代的

当依赖于 NumPy 内部实现的详细信息时,我们仍提醒子类的作者要慎重。不可能总是编写一个完美可替代的 ndarray 子类,例如,涉及创建新数组的情况,尤其是因为 NumPy 使用专门针对 base NumPy 数组的内部优化(例如,以 C 语言编写的代码)。即使 NumPy 的实现恰好适用于今天,但将来可能并不适用。在这些情况下,你的对策是通过子类对最顶层的 NumPy 函数重新实现 __array_function__

NumPy 函数内的变更#

给定一个定义以上行为的函数(现在就称它为 implement_array_function),现在我们需要从每个相关的 NumPy 函数中调用该函数。这是一个普遍的变更,但代码相当简单且无害,如果没有参数实现 __array_function__ 协议,它应快速完成且不产生任何效果。

为实现此目的,我们定义一个 array_function_dispatch 装饰器来重写 NumPy 函数。基本实现如下

def array_function_dispatch(dispatcher, module=None):
    """Wrap a function for dispatch with the __array_function__ protocol."""
    def decorator(implementation):
        @functools.wraps(implementation)
        def public_api(*args, **kwargs):
            relevant_args = dispatcher(*args, **kwargs)
            return implement_array_function(
                implementation, public_api, relevant_args, args, kwargs)
        if module is not None:
            public_api.__module__ = module
        # for ndarray.__array_function__
        public_api._implementation = implementation
        return public_api
    return decorator

# example usage
def _broadcast_to_dispatcher(array, shape, subok=None):
    return (array,)

@array_function_dispatch(_broadcast_to_dispatcher, module='numpy')
def broadcast_to(array, shape, subok=False):
    ...  # existing definition of np.broadcast_to

使用装饰器很棒!我们不需要更改现有 NumPy 函数的定义,只需要为调度函数写几行额外的代码。我们甚至可以对具有相同签名的函数族(例如,sumprod)重用一个调度程序。对于此类函数,最大的更改可能是在文档字符串中添加几行,以指出检查哪些参数以进行重载。

特别值得注意的是装饰器对 functools.wraps 的使用

  • 这确保了包装函数与包装 NumPy 函数具有相同的名称和文档字符串。

  • 在 Python 3 上,它还确保了装饰器函数复制了原始函数签名,这对基于自省的工具(如自动完成)很重要。

  • 最后,它确保了包装函数 可以被腌制

示例用法展示了与 NumPy 贡献者相关的编写调度程序的几个最佳实践

  • 我们传递了 module 参数,它反过来又设置了生成函数上的 __module__ 属性。这样做有利于提高错误消息的效果,例如 NumPy 在找不到实现时内部引发的错误,例如, TypeError: no implementation found for 'numpy.broadcast_to'。将 __module__ 设置为 NumPy 公共 API 中的规范位置,鼓励用户使用 NumPy 的公共 API 来识别 __array_function__ 中的函数。

  • 调度程序是一个返回元组的函数,而不是使用 yield 返回一个同等的(且同样有效的)生成器

    # example usage
    def broadcast_to(array, shape, subok=None):
        yield array
    

    这并不是巧合:NumPy 对 __array_function__ 的 dispatch 实现,在调度函数返回内置序列类型(tuplelist)时最快。

    与此相关的是,调度程序即使在某些情况下你知道它们不能有 __array_function__ 方法也是完全可以的。这种情况可能会发生在具有默认参数(例如,None)或复杂签名的函数上。NumPy 的调度逻辑会非常快速地解决这些情况,所以通常不值得自己去解析它们。

注意

上面 array_function_dispatch 的代码已从这个 NEP 的原始版本中更新,以匹配 在 NumPy 的实际实现

可扩展性#

该方法的一个重要优点是,它允许向 NumPy 函数添加新的可选参数,而无需破坏已依赖 __array_function__ 的代码。

这不是一个理论问题。NumPy 对重载的旧的、随意的实现 np.sum() 等函数之内,当我们决定添加新的可选参数(例如新的 keepdims 参数时),需要一些笨拙的体操功能,仅在使用的情况下才会传入

def sum(array, ..., keepdims=np._NoValue):
    kwargs = {}
    if keepdims is not np._NoValue:
        kwargs['keepdims'] = keepdims
    return array.sum(..., **kwargs)

对于 __array_function__ 的实现者来说,这也意味着有可能以增量方式实现甚至现有的可选参数,并且仅在有意义的情况下实现。例如,实现不可变数组的库不需要在函数签名中明确包含不受支持的 out 参数。正确实现这可能会有些繁琐,例如,

def my_sum(array, ..., out=None):
    if out is not None:
        raise TypeError('out argument is not supported')
    ...

因此,我们避免了鼓励使用诱人的捷径,即向 NumPy 调用的函数的签名中添加 catch-all **ignored_kwargs,这对拼写错误或被忽略的参数会静默失败。

性能#

性能一直是 NumPy 关注的问题,即使 NumPy 用户已经通过选择 Python 语言本身优先考虑可用性而不是纯粹的速度。重要的是,这个新的 __array_function__ 协议不应在 NumPy 函数作用于 NumPy 数组的典型情况下带来显著的成本。

我们的 微基准结果表明,上面描述的重载机制的纯 Python 实现向每个 NumPy 函数调用(没有任何重载的参数)添加了大约 2-3 微秒的开销。对于上下文,在小数组上的典型 NumPy 函数的运行时间为 1-10 微秒,主要由函数逻辑的哪一部分用 C 语言编写决定。例如,一微秒大约是 ndarray.sum() 方法(1.6 微秒)和 numpy.sum() 函数(2.6 微秒)之间的速度差。

幸运的是,我们预期使用 implement_array_function 的 C 实现会明显减少开销,因为运行的大部分时间都花在这里。这会让 array_function_dispatch 装饰器和分配器函数自己加上大约 0.5 微秒的开销,在典型情况下可能约为 1 微秒的开销。

我们认为,对于用 Python 编写的代码来说,这种级别的开销是可以接受的。我们很肯定,绝大多数 NumPy 用户都不在意 NumPy 函数的微秒级性能差异,因为在不到一微秒的时间内,用 Python “做点什么”都十分困难。

在 NumPy 之外的使用#

该协议中没有任何内容特定于 NumPy 本身。我们是否应该鼓励将同一 __array_function__ 协议用于第三方库,以重载非 NumPy 函数,例如,在 SciPy 中创建面向数组实现的通用功能?

这将带来显著的优势(SciPy 无需发明自己的分配系统),而且我们想不出任何弊端,因为每个使用 __array_function__ 进行分配的函数都需要显式识别。Dask、CuPy 和 Autograd 等库已经以类似于包装 NumPy 的方式包装了 SciPy 功能(例如,scipy.linalg)的有限子集。

如果我们想要这么做,就应该至少公开装饰器 array_function_dispatch(),还有可能公开更低级别的 implement_array_function(),作为 NumPy 公共 API 的一部分。

非目标#

我们追求的基本策略是在相对较短的时间内(单次 NumPy 版本的开发周期)相对机械化地应用于 NumPy API 中几乎所有函数。

我们希望一次性得到 __array_function__ 协议和所有特定的重载,但我们的明确目的是得到一些大致可行(并且可以迭代)的内容,而不是等待最优的实现。快速推进的代价是现在此协议应被严格视为实验性功能。我们保留在未来任何时候更改此协议的详细信息以及特定 NumPy 函数如何使用此协议的权利——即使在 NumPy 的仅 bug 修复版本中。在实践中,一旦解决 __array_function__ 的初始问题,我们将使用缩短的弃用周期,仅有一个主要的 NumPy 版本(例如,只需四个月)。

特别是,我们不打算编写额外的 NEP,其中列出所有特定的函数,以明确它们应当如何被重载。我们将把此权利留给各个拉取请求的提交者自行决定,相信他们会让感兴趣的各方提出所有争议供讨论。

但是,我们已经了解到一些函数系列应明确排除在 __array_function__ 之外。它们需要自己的协议

  • 通用函数,它们已经拥有自己的协议。

  • arrayasarray,因为它们明确用于强制转换为实际 numpy.ndarray 对象。

  • 任何类型的函数方法,例如 np.random.RandomState 对象上的方法。

我们还预计最初将使用 __array_function__ 协议来覆盖特定函数的机制在未来可以更改并且将会更改。关于我们如何预计在未来中断行为的一个具体示例,某些函数(例如 np.where)当前不是 NumPy 通用函数,但想象它们将来可能会成为通用函数。如果发生这种情况,我们将更改此类重载,从使用 __array_function__ 更改为更专用的__array_ufunc__

向后兼容性#

此提议不会更改现有的语义,除了目前具有 __array_function__ 属性的参数之外,该属性应该是罕见的。

备选#

专用协议#

我们可以(应该)继续为 NumPy 功能的内聚子集开发类似 __array_ufunc__ 的协议。

如上文所述,如果这意味着我们用 __array_function__ 重载的一些函数应当改为切换至一个新协议,那是可以的,只要 __array_function__ 保留其实验性状态。

切换至一个新协议应当采用 NumPy 普通弃用周期的精简版本

  • 对于一个单一的重大发行版,在检查任何新协议后,NumPy 应当仍然检查实现给定函数的 __array_function__ 方法。如果任何参数返回 __array_function__ 中的 NotImplemented 以外的值,应当发布一个描述性的 FutureWarning

  • 在下一个主要发行版中,将移除针对 __array_function__ 的检查。

单独的名称空间#

针对重载函数的单独名称空间是另外一个可能性,可以在 NumPy 外部或内部。

这具有消除任何可能的后向兼容性顾虑的优点,并将提供快速试验的最大自由度。从长远来看,它会提供一个清晰的抽象层,将 NumPy 的高级 API 从 numpy.ndarray 对象的默认实现中分离出来。

缺点是这将需要对所有现有的代码进行显式选择加入,例如,import numpy.api as np,并从长远来看会导致维护两个单独的 NumPy API。另外,numpy 本身的许多函数本身就已经重载了(但不够充分),因此 NumPy 中高级 API 和低级 API 之间的混淆仍然存在。

另外,可以为 NumPy 高级 API 创建一个单独的名称空间,例如 numpy.array_only,用于 NumPy 数组的性能至关重要的场合。这具有与单独名称空间几乎相同的所有缺点。

多重分派#

针对我们提出的 __array_function__ 协议的替代方法是将 NumPy 的核心函数实现为多方法。虽然我们中的一个人为 Python 编写了一个多方法库,但我们认为这种方法对于短期内的 NumPy 而言是不合适的。

主要原因在于 NumPy 已经有了基于 Python 自身用于算术运算的调度系统(__array_ufunc__),所以不应再引入一个的工作方式截然不同的调度机制。在 NumPy 自身上进行这一修改也会造成更多影响,其需要实现多重调度。

随着时间的推移,为 NumPy 的高级 API 实现多重调度可能会更有意义。幸运的是,__array_function__ 并不排除这种可能性,因为它能够很直接地为默认 __array_function__ 根据多重调度编写一个垫片实现。

以有限核心 API 为基础的实现#

一些 NumPy 函数的内部实现非常简单。例如

  • np.stack() 仅仅通过将索引与 np.newaxis、np.concatenate 和 shape 属性相结合,以几行代码实现。

  • np.mean() 在内部是用 np.sum()、np.divide()、.astype() 和 .shape 实现的。

这表明了定义一个最小“核心”ndarray 接口,并在 NumPy 中以它为基础实现完整 API 的可能性。这是一个有吸引力的选择,因为它可以显著减少用于实现各种数组的所需工作量。

但是,它也有一些缺点

  1. 现在 NumPy 如何用重载函数实现高级函数的详细信息成为了 NumPy 公共 API 的隐式部分。例如,重构 stack,使其在内部使用 np.block() 而不是 np.concatenate() ,现在成了一个重大更改。

  2. 数组库可能希望用不同于 NumPy 的方式实现高级函数。例如,一个库可能希望直接实现诸如 mean() 之类的基本运算,而不是依靠 sum() 后跟除法。更为一般地说,现在还不清楚什么是核心功能,弄清楚这一点可能是一个大项目。

  3. 我们现在还没有针对数组对象上的属性和方法进行重载的系统,例如用来访问 .dtype.shape。这应该是未来 NEP 中要解决的问题,但在那之前,我们应该谨慎使用这些属性。

鉴于这些问题,我们认为支持对 NumPy API 中的几乎每个公共函数进行显式重载很有价值。这并不排除将来使用 __array_function__ 和对于确保数组公开像 numpy.ndarray 这样的方法和属性的协议或基类,来重写 NumPy 函数的机会。然而,要使它很好地工作,就必须存在使用 __array_function__ 来实现某些(但不是全部)函数的可能性,例如下一节中所述。

NumPy API 的部分实现#

通过当前设计,实现 __array_function__ 以至少重载一个函数的类隐式声明了一种实现整个 NumPy API 的意图。不可能只对某个类型实现 np.concatenate(),而对所有其他函数回退到 NumPy 使用 np.asarray() 进行强制转换的默认行为。

这可能会引发一个向后兼容性问题,从而使库在逐步采用 __array_function__ 时犹豫不决。例如,目前大多数 numpy 函数都会将 pandas.Series 对象隐式转换为 NumPy 数组,这是许多 pandas 用户依赖的行为。如果 pandas 只针对 np.concatenate 实现 __array_function__,那么 np.nanmean 等无关的 NumPy 函数就会突然在 pandas 对象上中断,从而引发 TypeError。

即便重新实现 NumPy 大部分公共 API 的库,有时也会依赖于在没有任何包装器的情况下使用 NumPy 中的实用程序函数。例如,CuPy 和 JAX 只会 使用一个别名 来指向 np.result_type,后者已经使用具有 dtype 属性的 duck-types。

使用 __array_ufunc__,可以通过将所有参数强制转换为 numpy 数组并重新调用 ufunc 来缓解此问题,但 __array_function__ 所支持的不同函数签名使得无法为 __array_function__ 实现这种通用的回退行为。

我们考虑了三种解决此问题的方法,但没有一种是完全令人满意的。

  1. 将从 __array_function__ 返回 NotImplemented 的所有参数的含义改为指示应将所有参数强制转换为 NumPy 数组并重试操作。但是,许多数组库(例如 scipy.sparse)实际上并不需要隐式转换为 NumPy 数组,并且常常避免实现 __array__ 正是为了这个原因。隐式转换可能导致不可察觉的错误和性能下降。

    我们有可能只为使用 __array__ 的类型启用此行为,这将解决像 scipy.sparse 这样的最成问题的情况。但在实践中,许多呈现类似 NumPy 数组的高级 API 的类已经实现了 __array__。这将阻止在这些对象上可靠使用 NumPy 的高级 API。

  2. 使用某种其他哨兵值,例如 np.NotImplementedButCoercible,以指示实现 NumPy 更高级别数组 API 的类的部分内容是可强制转换的,作为后备。如果所有参数均返回 NotImplementedButCoercible,则将强制转换参数并重试操作。

    不幸的是,在遇到 NotImplementedButCoercible 后的正确行为并不总是显而易见的。特别具有挑战性的是“混合”的情况,其中一些参数返回 NotImplementedButCoercible,而其他参数返回 NotImplemented。是否仅强制转换“可强制”参数后重新调度?如果是这样,那么我们可以想象会无限次地遍历调度逻辑。无论哪种方式,调度规则肯定变得更加复杂,更难推理。

  3. 允许访问 NumPy 的函数实现,例如以公开公开 __skip_array_function__ 属性的形式,用于 NumPy 函数。这样就允许通过在 __array_function__ 方法中使用 func.__skip_array_function__ 来回退到 NumPy 实现,并且有可能还用于避免调度的开销。但是,它存在潜在风险,可能会公开 NumPy 函数的 NumPy 实现的详细信息,而这些函数不会在内部调用 np.asarray()。有关完整讨论的摘要,请参见此注解

这些解决方案能解决实际用例,但代价是增加了复杂性。我们希望先积累关于 __array_function__ 实际用法方面的经验,然后再做出难以回滚的决定。

检查类型注释的魔术装饰器#

原则上,Python 3 类型注释包含足够的信息来自动创建大多数 dispatcher 函数。使用这些注释来消除手动编写调度程序的需要会很方便,例如:

@array_function_dispatch
def broadcast_to(array: ArrayLike
                 shape: Tuple[int, ...],
                 subok: bool = False):
    ...  # existing definition of np.broadcast_to

这需要某种形式的自动代码生成,或者在编译时或导入时。

我们认为这是一个有趣的未来可能扩展。我们认为现在这样做没有意义,因为代码生成涉及权衡,而 NumPy 在类型注释方面的经验仍 非常有限。即使 NumPy 仅为 Python 3(这将在 2019 年的某个时间 发生),我们也尚未准备好直接为 NumPy 代码库添加注释。

对实现特定参数的支持#

我们可以允许 __array_function__ 实现通过在调度程序函数中包含 **ignored_kwargs 来添加它们自己的可选关键字参数,例如:

def _concatenate_dispatcher(arrays, axis=None, out=None, **ignored_kwargs):
    ...  # same implementation of _concatenate_dispatcher as above

以某种方式模仿 NumPy 更高级别 API 的库中实现特定参数比较常见(例如, dask.array.sum() 添加 split_everytensorflow.reduce_sum() 添加 name)。在 NumPy 中支持它们对于在 NumPy 函数之上实现新高级别数组函数的库特别有用,例如:

def mean_squared_error(x, y, **kwargs):
    return np.mean((x - y) ** 2, **kwargs)

否则,我们需要 mean_squared_error 的各个数组实现的单独版本,才能将特定于实现的参数传递给 mean()

我们不会允许添加可选位置参数,因为这些参数专供 NumPy 本身将来使用,但关键字参数之间的冲突应该是比较罕见的。

然而,这种灵活性会带来代价。特别是,在未实际包含它的情况下,它隐式将 **kwargs 添加到所有包装的 NumPy 函数的签名(因为我们使用 functools.wraps)。这意味着它不太可能适用于静态分析工具,这些工具可能会报告无效的参数。同样,可读性也会受到影响:这些可选参数不会包含在 NumPy 函数的文档字符串中。

目前还不清楚这种权衡是否有价值,因此我们建议先将其排除在外。添加特定于实现的参数需要直接使用这些库。

协议的其它可能选择#

数组函数 __array_function__ 仅包括两个参数,functypes,它们提供有关函数调用环境的信息。

func 是协议的一部分,因为它无法避免:实现需要能够通过匹配函数到 NumPy 的公共 API 来分派。

types 包含在内是因为我们几乎可以免费计算它作为收集 __array_function__ 实现的一部分,以便在 implement_array_function 中进行调用。我们还认为许多 __array_function__ 方法会使用它,否则就需要自己提取这些信息。提供每种类型的单个实例同样容易,但仅提供类型看起来更简洁。

更进一步,有人建议 __array_function__ 应为 classmethod。我们同意删除冗余的 self 参数会更简洁一些,但觉得这次微小的清理不值得打破 __array_ufunc__ 的优先次序。

还有两个参数我们认为可能对于传递给 __array_ufunc__ 实现很重要

  • ndarray.__array_function__ 中访问未分派的实现(即,使用 array_function_dispatch 包装之前),可以让我们从 implement_array_function 中删除该方法的特殊情况逻辑。

  • 传递给 array_function_dispatch()dispatcher 函数的访问权限将允许 __array_function__ 实施通过调用 dispatcher(*args, **kwargs) 以通用方式确定“类数组”参数的列表。这可能对基于数组属性的值(例如 dtypeunits)而非直接根据数组类型分派的 __array_function__ 实施有用。

目前,我们已将它们排除在外,因为我们不知道它们是否必要。如果我们希望在将来将它们包含,那么最简单的做法是更新 array_function_dispatch 装饰器以将它们作为函数属性添加。

在运行时生成的调用对象#

NumPy 有一些 API 以动态方式定义可调用对象,例如 vectorize 以及 random.RandomState 对象上的方法。还可以在科学 Python 堆栈中的其他核心库中找到示例,例如 scipy.stats 中的分布对象以及 scikit-learn 中的模型对象。能够针对此类可调用对象编写重载也很不错。这对 __array_function__ 协议提出了挑战,因为与函数不同, numpy 命名空间中没有公有对象可传递给 func 参数。

我们可以通过为如何检查 func 参数建立替代约定来处理这个问题,例如通过使用 func.__self__ 获取类对象,并通过 func.__func__ 返回未绑定函数对象。但是,需要谨慎使用,因为这会将当前的实现细节作为接口的永久特征,例如 vectorize 是作为类而非闭包实现,或者某个方法是直接实现还是使用描述符实现。

鉴于复杂性和有限的用例,我们目前还在推迟解决此问题,但我们确信如果需要的话,未来可以扩展 __array_function__ 以适应这些用例。

讨论#

在少数 GitHub 问题中讨论了此提案的各种替代方案。

  1. pydata/sparse #1

  2. numpy/numpy #11129

此外,这也是 一篇博文 的主题。随之,它在一个 NumPy 开发者冲刺 会议中得到了讨论,该会议在 加州大学伯克利分校数据科学研究院 (BIDS) 举行。

可以在 邮件列表 上找到对该提案本身的详细讨论,以及相关的 pull 请求 (1, 2, 3)