NumPy 的互操作性#

NumPy 的 ndarray 对象既提供了对数组结构化数据进行操作的高级 API,也提供了基于内存中的跨步存储的 API 的具体实现。虽然这个 API 功能强大且相当通用,但它的具体实现存在局限性。随着数据集的增长以及 NumPy 在各种新环境和架构中的使用,在某些情况下,内存中的跨步存储策略并不适用,这导致不同的库为自身用途重新实现了此 API。这包括 GPU 数组(CuPy)、稀疏数组(scipy.sparsePyData/Sparse)和并行数组(Dask 数组),以及深度学习框架中的各种类似 NumPy 的实现,例如TensorFlowPyTorch。同样,也有许多项目建立在 NumPy API 之上,用于标记和索引数组(XArray)、自动微分(JAX)、掩码数组(numpy.ma)、物理单位(astropy.unitspintunyt)等,这些都为 NumPy API 增加了额外的功能。

尽管如此,用户仍然希望使用熟悉的 NumPy API 来处理这些数组,并以最小(理想情况下为零)的移植开销重用现有代码。考虑到这一目标,定义了各种协议,用于实现具有与 NumPy 匹配的高级 API 的多维数组。

总的来说,用于与 NumPy 互操作的功能分为三类:

  1. 将外部对象转换为 ndarray 的方法;

  2. 将执行从 NumPy 函数推迟到另一个数组库的方法;

  3. 使用 NumPy 函数并返回外部对象实例的方法。

下面将详细介绍这些功能。

1. 在 NumPy 中使用任意对象#

NumPy API 的第一组互操作性功能允许在可能的情况下将外部对象视为 NumPy 数组。当 NumPy 函数遇到外部对象时,它们会尝试(按顺序)执行以下操作:

  1. Python C-API 文档中描述的缓冲区协议

  2. 协议,在本页面中进行描述。它是 Python 缓冲区协议的前身,它定义了一种从其他 C 扩展访问 NumPy 数组内容的方法。__array_interface__

  3. 方法,该方法要求任意对象将其自身转换为数组。__array__()

对于缓冲区和__array_interface__协议,对象描述其内存布局,NumPy 完成其余工作(如果可能,则为零拷贝)。如果不可能,则对象本身负责从__array__()返回一个ndarray

DLPack 是另一种以语言和设备无关的方式将外部对象转换为 NumPy 数组的协议。NumPy 不会隐式地使用 DLPack 将对象转换为 ndarray。它提供了函数numpy.from_dlpack,该函数接受任何实现__dlpack__ 方法的对象,并输出一个 NumPy ndarray(通常是输入对象数据缓冲区的视图)。DLPack 的 Python 规范 页面详细解释了__dlpack__ 协议。

dtype 互操作性#

与数组对象的__array__()类似,定义__numpy_dtype__允许自定义 dtype 对象与 NumPy 互操作。__numpy_dtype__必须返回一个 NumPy dtype 实例(请注意,np.float64不是 dtype 实例,np.dtype(np.float64)才是)。

2.4 版新增: 在 NumPy 2.4 之前,.dtype属性被类似对待。从 NumPy 2.4 开始,两者都被接受,并且实现__numpy_dtype__会阻止检查.dtype

数组接口协议#

数组接口协议定义了一种方式,允许类数组对象重用彼此的数据缓冲区。它的实现依赖于以下属性或方法的存在:

  • __array_interface__:一个 Python 字典,包含数组对象的形状、元素类型,以及可选的数据缓冲区地址和跨步。 

  • __array__():一个返回 NumPy ndarray 副本或视图的数组对象的函数;

可以直接检查__array_interface__属性:

>>> import numpy as np
>>> x = np.array([1, 2, 5.0, 8])
>>> x.__array_interface__
{'data': (94708397920832, False), 'strides': None, 'descr': [('', '<f8')], 'typestr': '<f8', 'shape': (4,), 'version': 3}

__array_interface__属性也可以用于就地修改对象数据:

>>> class wrapper():
...     pass
...
>>> arr = np.array([1, 2, 3, 4])
>>> buf = arr.__array_interface__
>>> buf
{'data': (140497590272032, False), 'strides': None, 'descr': [('', '<i8')], 'typestr': '<i8', 'shape': (4,), 'version': 3}
>>> buf['shape'] = (2, 2)
>>> w = wrapper()
>>> w.__array_interface__ = buf
>>> new_arr = np.array(w, copy=False)
>>> new_arr
array([[1, 2],
       [3, 4]])

我们可以检查arrnew_arr是否共享相同的数据缓冲区:

>>> new_arr[0, 0] = 1000
>>> new_arr
array([[1000,    2],
       [   3,    4]])
>>> arr
array([1000, 2, 3, 4])

__array__()方法#

__array__()方法确保任何实现它的 NumPy 类对象(数组、公开数组接口的任何对象、其__array__()方法返回数组的任何对象或任何嵌套序列)都可以用作 NumPy 数组。如果可能,这将意味着使用__array__()来创建一个类数组对象的 NumPy ndarray 视图。否则,这会将数据复制到一个新的 ndarray 对象中。这不是最优的,因为将数组强制转换为 ndarray 可能会导致性能问题,或者需要复制并丢失元数据,因为原始对象及其可能拥有的任何属性/行为都将丢失。

方法的签名应该是__array__(self, dtype=None, copy=None)。如果传递的dtype不是None且与对象的数据类型不同,则应将其转换为指定类型。如果copyNone,则仅当dtype参数强制要求时才应进行复制。对于copy=True,应始终进行复制,而copy=False在需要复制时应引发异常。

如果一个类实现了旧的签名__array__(self),那么对于np.array(a),将会发出一个警告,说明缺少dtypecopy参数。

有关自定义数组实现(包括使用__array__())的示例,请参阅编写自定义数组容器

DLPack 协议#

DLPack 协议定义了跨步 n 维数组对象的内存布局。它提供了以下数据交换语法:

  1. 一个numpy.from_dlpack函数,它接受具有__dlpack__方法的(数组)对象,并使用该方法构造一个包含x数据的数组。

  2. 数组对象上的__dlpack__(self, stream=None)__dlpack_device__方法,它们将在from_dlpack内部调用,以查询数组所在的设备(可能需要传入正确的流,例如在有多个 GPU 的情况下)以及访问数据。

与缓冲区协议不同,DLPack 允许交换包含 CPU 以外设备(例如 Vulkan 或 GPU)数据的数组。由于 NumPy 只支持 CPU,因此它只能转换数据存在于 CPU 上的对象。但是,其他库,如PyTorchCuPy,可以使用此协议交换 GPU 上的数据。

2. 在不转换的情况下操作外部对象#

NumPy API 定义的第二组方法允许我们将执行从 NumPy 函数推迟到另一个数组库。

考虑以下函数。

>>> import numpy as np
>>> def f(x):
...     return np.mean(np.exp(x))

请注意,np.exp 是一个ufunc,这意味着它以逐元素的方式对 ndarray 进行操作。另一方面,np.mean 沿数组的一个轴进行操作。

我们可以直接将f应用于 NumPy ndarray 对象:

>>> x = np.array([1, 2, 3, 4])
>>> f(x)
21.1977562209304

我们希望此函数对任何类 NumPy 的数组对象都能同样好地工作。

NumPy 允许类通过以下接口指示其希望以自定义定义的方式处理计算:

  • __array_ufunc__:允许第三方对象支持和覆盖ufuncs

  • __array_function__:一个用于 NumPy 功能的通用接口,这些功能不被通用函数__array_ufunc__协议涵盖。

只要外部对象实现了__array_ufunc____array_function__协议,就可以在不需要显式转换的情况下对其进行操作。

__array_ufunc__协议#

通用函数(或简称 ufunc)是一个“矢量化”的函数包装器,它接受固定数量的特定输入并产生固定数量的特定输出。ufunc(及其方法)的输出不一定是 ndarray,如果输入参数不是所有 ndarray 的话。事实上,如果任何输入定义了__array_ufunc__方法,控制权将完全传递给该函数,即 ufunc 被覆盖。在(非 ndarray)对象上定义的__array_ufunc__方法可以访问 NumPy ufunc。由于 ufunc 具有明确定义的结构,因此外部__array_ufunc__方法可以依赖于 ufunc 属性,例如.at().reduce()等。

通过覆盖默认的ndarray.__array_ufunc__方法,子类可以覆盖在对其执行 NumPy ufunc 时发生的情况。此方法将在 ufunc 之前执行,并且应返回操作的结果,或在操作未实现时返回NotImplemented

__array_function__协议#

为了获得足够的 NumPy API 覆盖范围来支持下游项目,需要超越__array_ufunc__并实现一个协议,该协议允许 NumPy 函数的参数接管并以一种安全且跨项目一致的方式将执行重定向到另一个函数(例如,GPU 或并行实现)。

__array_function__的语义与__array_ufunc__非常相似,只是操作由任意可调用对象而不是 ufunc 实例和方法指定。有关更多详细信息,请参阅NEP 18 — A dispatch mechanism for NumPy’s high level array functions

3. 返回外部对象#

第三种功能集旨在利用 NumPy 函数实现,然后将返回值转换回外部对象实例。__array_finalize____array_wrap__方法在后台运行,以确保 NumPy 函数的返回类型可以根据需要指定。

__array_finalize__方法是 NumPy 提供的机制,允许子类处理创建新实例的各种方式。每当系统从 ndarray 的子类(子类型)对象内部分配新数组时,都会调用此方法。它可用于在构造后更改属性,或更新“父”的元信息。

__array_wrap__方法在某种意义上“包装了操作”,允许任何对象(如用户定义的函数)设置其返回值的类型并更新属性和元数据。这可以看作是__array__方法的反面。在实现__array_wrap__的每个对象的末尾,都会调用此方法,输入对象是具有最高*数组优先级*的对象,或者是在指定了输出对象的情况下调用输出对象。__array_priority__属性用于确定在返回对象的 Python 类型有多种可能性时要返回的对象类型。例如,子类可以选择使用此方法将输出数组转换为子类的实例,并在将数组返回给用户之前更新元数据。

有关这些方法的更多信息,请参阅子类化 ndarrayndarray 子类型的特定功能

互操作性示例#

示例:Pandas Series 对象#

考虑以下:

>>> import pandas as pd
>>> ser = pd.Series([1, 2, 3, 4])
>>> type(ser)
pandas.core.series.Series

现在,ser **不是** ndarray,但因为它实现了 __array_ufunc__ 协议,我们可以像对待 ndarray 一样对其应用 ufuncs:

>>> np.exp(ser)
   0     2.718282
   1     7.389056
   2    20.085537
   3    54.598150
   dtype: float64
>>> np.sin(ser)
   0    0.841471
   1    0.909297
   2    0.141120
   3   -0.756802
   dtype: float64

我们甚至可以进行与其他 ndarray 的运算:

>>> np.add(ser, np.array([5, 6, 7, 8]))
   0     6
   1     8
   2    10
   3    12
   dtype: int64
>>> f(ser)
21.1977562209304
>>> result = ser.__array__()
>>> type(result)
numpy.ndarray

示例:PyTorch 张量#

PyTorch 是一个用于深度学习的优化张量库,可用于 GPU 和 CPU。PyTorch 数组通常称为*张量*。张量与 NumPy 的 ndarray 类似,不同之处在于张量可以在 GPU 或其他硬件加速器上运行。事实上,张量和 NumPy 数组通常可以共享相同的底层内存,从而无需复制数据。

>>> import torch
>>> data = [[1, 2],[3, 4]]
>>> x_np = np.array(data)
>>> x_tensor = torch.tensor(data)

请注意,x_npx_tensor是不同类型的对象:

>>> x_np
array([[1, 2],
       [3, 4]])
>>> x_tensor
tensor([[1, 2],
        [3, 4]])

但是,我们可以将 PyTorch 张量视为 NumPy 数组,而无需显式转换:

>>> np.exp(x_tensor)
tensor([[ 2.7183,  7.3891],
        [20.0855, 54.5982]], dtype=torch.float64)

此外,请注意此函数的返回类型与初始数据类型兼容。

警告

尽管混合使用 ndarray 和张量可能很方便,但不推荐这样做。它不能用于非 CPU 张量,并且在边缘情况下会产生意外行为。用户应优先显式将 ndarray 转换为张量。

注意

PyTorch 未实现__array_function____array_ufunc__。在底层,Tensor.__array__()方法返回一个 NumPy ndarray,作为张量数据缓冲区的视图。有关详细信息,请参阅此问题__torch_function__ 实现

另请注意,我们可以在此处看到__array_wrap__的作用,尽管torch.Tensor不是 ndarray 的子类:

>>> import torch
>>> t = torch.arange(4)
>>> np.abs(t)
tensor([0, 1, 2, 3])

PyTorch 实现了__array_wrap__,以便能够从 NumPy 函数返回张量,我们可以直接修改它来控制从这些函数返回的对象类型。

示例:CuPy 数组#

CuPy 是一个 NumPy/SciPy 兼容的数组库,用于 Python 的 GPU 加速计算。CuPy 通过实现cupy.ndarrayNumPy ndarrays 的对应物)来实现 NumPy 接口的子集。

>>> import cupy as cp
>>> x_gpu = cp.array([1, 2, 3, 4])

对象实现了__array_ufunc__接口。cupy.ndarray这使得 NumPy ufuncs 可以应用于 CuPy 数组(这将把操作推迟到 ufunc 的匹配 CuPy CUDA/ROCm 实现):

>>> np.mean(np.exp(x_gpu))
array(21.19775622)

请注意,这些操作的返回类型仍然与初始类型一致:

>>> arr = cp.random.randn(1, 2, 3, 4).astype(cp.float32)
>>> result = np.sum(arr)
>>> print(type(result))
<class 'cupy._core.core.ndarray'>

有关详细信息,请参阅CuPy 文档中的此页面

还实现了__array_function__接口,这意味着可以执行诸如以下操作:cupy.ndarray

>>> a = np.random.randn(100, 100)
>>> a_gpu = cp.asarray(a)
>>> qr_gpu = np.linalg.qr(a_gpu)

CuPy 在cupy.ndarray对象上实现了许多 NumPy 函数,但并非全部。有关详细信息,请参阅CuPy 文档

示例:Dask 数组#

Dask 是 Python 中一个灵活的并行计算库。Dask Array 使用分块算法实现了 NumPy ndarray 接口的子集,将大数组分割成许多小数组。这使得可以使用多核处理大于内存的数组进行计算。

Dask 支持__array__()__array_ufunc__

>>> import dask.array as da
>>> x = da.random.normal(1, 0.1, size=(20, 20), chunks=(10, 10))
>>> np.mean(np.exp(x))
dask.array<mean_agg-aggregate, shape=(), dtype=float64, chunksize=(), chunktype=numpy.ndarray>
>>> np.mean(np.exp(x)).compute()
5.090097550553843

注意

Dask 是惰性求值的,计算结果在你通过调用compute()来请求它之前不会被计算。

有关详细信息,请参阅Dask 数组文档以及Dask 数组与 NumPy 数组互操作性的范围

示例:DLPack#

几个 Python 数据科学库实现了__dlpack__协议。其中有PyTorchCuPy。可以在DLPack 文档的此页面上找到实现此协议的库的完整列表。

将 PyTorch CPU 张量转换为 NumPy 数组

>>> import torch
>>> x_torch = torch.arange(5)
>>> x_torch
tensor([0, 1, 2, 3, 4])
>>> x_np = np.from_dlpack(x_torch)
>>> x_np
array([0, 1, 2, 3, 4])
>>> # note that x_np is a view of x_torch
>>> x_torch[1] = 100
>>> x_torch
tensor([  0, 100,   2,   3,   4])
>>> x_np
array([  0, 100,   2,   3,   4])

导入的数组是只读的,因此写入或就地操作将失败:

>>> x.flags.writeable
False
>>> x_np[1] = 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: assignment destination is read-only

为了就地操作导入的数组,必须创建一个副本,但这将意味着复制内存。对于非常大的数组,请不要这样做:

>>> x_np_copy = x_np.copy()
>>> x_np_copy.sort()  # works

注意

请注意,GPU 张量无法转换为 NumPy 数组,因为 NumPy 不支持 GPU 设备:

>>> x_torch = torch.arange(5, device='cuda')
>>> np.from_dlpack(x_torch)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: Unsupported device in DLTensor.

但是,如果两个库都支持数据缓冲区所在的设备,则可以使用__dlpack__协议(例如,PyTorchCuPy):

>>> x_torch = torch.arange(5, device='cuda')
>>> x_cupy = cupy.from_dlpack(x_torch)

同样,NumPy 数组可以转换为 PyTorch 张量:

>>> x_np = np.arange(5)
>>> x_torch = torch.from_dlpack(x_np)

只读数组无法导出。

>>> x_np = np.arange(5)
>>> x_np.flags.writeable = False
>>> torch.from_dlpack(x_np)  
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../site-packages/torch/utils/dlpack.py", line 63, in from_dlpack
    dlpack = ext_tensor.__dlpack__()
TypeError: NumPy currently only supports dlpack for writeable arrays

进一步阅读#