与 NumPy 的互操作性#
NumPy 的 ndarray 对象同时提供了用于操作数组结构化数据的高级 API 和基于步幅式内存存储的 API 的具体实现。虽然此 API 功能强大且相当通用,但其具体实现存在局限性。随着数据集的增长以及 NumPy 在各种新的环境和架构中的使用,步幅式内存存储策略不适用的情况也出现了,这导致不同的库为自身用途重新实现了此 API。这包括 GPU 数组(CuPy)、稀疏数组(scipy.sparse
,PyData/Sparse)和并行数组(Dask 数组),以及深度学习框架中各种类似 NumPy 的实现,例如 TensorFlow 和 PyTorch。同样,许多项目都基于 NumPy API 构建,用于标记和索引数组(XArray)、自动微分(JAX)、掩码数组(numpy.ma
)、物理单位(astropy.units,pint,unyt)等等,这些都增加了 NumPy API 之上的额外功能。
然而,用户仍然希望使用熟悉的 NumPy API 来处理这些数组,并以最少(理想情况下为零)的移植开销来重用现有代码。考虑到这一目标,为具有与 NumPy 匹配的高级 API 的多维数组的实现定义了各种协议。
广义地说,有三种用于与 NumPy 互操作的功能组
将外部对象转换为 ndarray 的方法;
将 NumPy 函数的执行延迟到另一个数组库的方法;
使用 NumPy 函数并返回外部对象实例的方法。
我们在下面描述这些功能。
1. 在 NumPy 中使用任意对象#
NumPy API 的第一组互操作性功能允许在尽可能的情况下将外部对象视为 NumPy 数组。当 NumPy 函数遇到外部对象时,它将尝试(按顺序)
缓冲区协议,在 Python C-API 文档中描述。
__array_interface__
协议,在此页面中描述。它是 Python 缓冲区协议的前身,它定义了一种从其他 C 扩展访问 NumPy 数组内容的方法。__array__()
方法,它要求任意对象将自身转换为数组。
对于缓冲区和 __array_interface__
协议,对象都描述了其内存布局,NumPy 完成其余工作(如果可能,则为零拷贝)。如果不可能,对象本身负责从 __array__()
返回一个 ndarray
。
DLPack 是另一种协议,用于以与语言和设备无关的方式将外部对象转换为 NumPy 数组。NumPy 不会隐式地使用 DLPack 将对象转换为 ndarray。它提供了函数 numpy.from_dlpack
,该函数接受实现 __dlpack__
方法的任何对象并输出 NumPy ndarray(通常是输入对象数据缓冲区的视图)。DLPack 的 Python 规范 页面详细解释了 __dlpack__
协议。
数组接口协议#
数组接口协议定义了一种方法,用于让类似数组的对象重用彼此的数据缓冲区。其实现依赖于以下属性或方法的存在
__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]])
我们可以检查 arr
和 new_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
并且与对象的 数据类型不同,则应将其转换为指定的类型。如果 copy
为 None
,则仅当 dtype
参数强制执行时才应进行复制。对于 copy=True
,应始终进行复制,而 copy=False
应该在需要复制时引发异常。
如果一个类实现了旧签名 __array__(self)
,则对于 np.array(a)
,将发出警告,提示缺少 dtype
和 copy
参数。
要查看包含 __array__()
用法的自定义数组实现示例,请参阅 编写自定义数组容器。
DLPack 协议#
DLPack 协议定义了步幅式 n 维数组对象的内存布局。它提供了以下用于数据交换的语法
一个
numpy.from_dlpack
函数,它接受具有__dlpack__
方法的(数组)对象,并使用该方法构造一个包含来自x
的数据的新数组。__dlpack__(self, stream=None)
和__dlpack_device__
方法在数组对象上,它将在from_dlpack
中调用,以查询数组所在的设备(可能需要传入正确的流,例如在多个 GPU 的情况下)并访问数据。
与缓冲区协议不同,DLPack 允许交换包含除 CPU(例如 Vulkan 或 GPU)之外的设备上的数据的数组。由于 NumPy 仅支持 CPU,因此它只能转换其数据存在于 CPU 上的对象。但是其他库,例如 PyTorch 和 CuPy,可以使用此协议交换 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__
:对于通用函数__array_ufunc__
协议未涵盖的 NumPy 功能的通用方法。
只要外部对象实现了 __array_ufunc__
或 __array_function__
协议,就可以对其进行操作而无需显式转换。
__array_ufunc__
协议#
一个 通用函数(简称 ufunc) 是一个“矢量化”的函数包装器,它接受固定数量的特定输入并产生固定数量的特定输出。如果并非所有输入参数都是 ndarray,则 ufunc(及其方法)的输出不一定是 ndarray。实际上,如果任何输入定义了 __array_ufunc__
方法,则控制权将完全传递给该函数,即 ufunc 将被覆盖。__array_ufunc__
方法定义在该(非 ndarray)对象上,可以访问 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 — NumPy 高级数组函数的调度机制。
3. 返回外部对象#
第三种类型的功能集旨在使用 NumPy 函数实现,然后将返回值转换回外部对象的实例。__array_finalize__
和 __array_wrap__
方法在幕后起作用,以确保可以根据需要指定 NumPy 函数的返回类型。
__array_finalize__
方法是 NumPy 提供的机制,允许子类处理创建新实例的各种方式。每当系统从 ndarray 的子类(子类型)的对象内部分配一个新数组时,都会调用此方法。它可以用于在构造后更改属性,或从“父”对象更新元信息。
__array_wrap__
方法“包装操作”,允许任何对象(例如用户定义的函数)设置其返回值的类型并更新属性和元数据。这可以看作是 __array__
方法的反面。在实现 __array_wrap__
的每个对象的末尾,都会使用具有最高 *数组优先级* 的输入对象或指定的输出对象调用此方法。__array_priority__
属性用于确定在返回对象的 Python 类型有多种可能性时返回哪种类型的对象。例如,子类可以选择使用此方法将输出数组转换为子类的实例并在返回数组给用户之前更新元数据。
有关这些方法的更多信息,请参阅 ndarray 的子类化 和 ndarray 子类型的特定功能。
互操作性示例#
示例:Pandas Series
对象#
考虑以下情况:
>>> import pandas as pd
>>> ser = pd.Series([1, 2, 3, 4])
>>> type(ser)
pandas.core.series.Series
现在,ser
**不是** ndarray,但因为它 实现了 __array_ufunc__ 协议,我们可以像对待 ndarray 一样对其应用 ufunc。
>>> 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_np
和 x_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.ndarray
(NumPy ndarray 的对应物)来实现 NumPy 接口的一个子集。
>>> import cupy as cp
>>> x_gpu = cp.array([1, 2, 3, 4])
cupy.ndarray
对象实现了 __array_ufunc__
接口。这使得 NumPy ufunc 可以应用于 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 文档中的此页面。
cupy.ndarray
还实现了 __array_function__
接口,这意味着可以执行以下操作:
>>> 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__
协议。其中包括 PyTorch 和 CuPy。可以在 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__
协议(例如 PyTorch 和 CuPy)。
>>> 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
进一步阅读#
特殊属性和方法 (
__array_ufunc__
和__array_function__
协议的详细信息)ndarray 的子类化 (
__array_wrap__
和__array_finalize__
方法的详细信息)ndarray 子类型化的特定功能 (
__array_finalize__
,__array_wrap__
和__array_priority__
实现的更多细节)