与 NumPy 的互操作性#
NumPy 的 ndarray 对象既提供了用于对数组结构化数据进行操作的高级 API,又提供了基于 strided in-RAM storage 的 API 的具体实现.虽然此 API 功能强大且相当通用,但其具体实现在某些方面存在局限性.随着数据集的增长以及 NumPy 在各种新环境和架构中的使用,在某些情况下,跨步式内存存储策略是不合适的,这导致不同的库为自己的用途重新实现此 API.这包括 GPU 数组 ( CuPy ),稀疏数组 ( scipy.sparse , PyData/Sparse ) 和并行数组 (Dask 数组) 以及深度学习框架(如 TensorFlow 和 PyTorch )中的各种类似 NumPy 的实现.同样,有许多项目建立在 NumPy API 之上,用于标记和索引数组 (XArray),自动微分 (JAX),掩码数组 ( numpy.ma ),物理单位 (astropy.units, pint, unyt) 等,这些项目在 NumPy API 之上添加了其他功能.
然而,用户仍然希望使用熟悉的 NumPy API 来处理这些数组,并尽可能减少(理想情况下为零)移植开销来重用现有代码.考虑到这个目标,已经为多维数组的实现定义了各种协议,这些协议的高级 API 与 NumPy 相匹配.
一般来说,与 NumPy 互操作使用的特性有三组:
将外部对象转换为 ndarray 的方法;
将 NumPy 函数的执行推迟到另一个数组库的方法;
使用 NumPy 函数并返回外部对象实例的方法.
我们在下面描述这些特性.
1. 在 NumPy 中使用任意对象#
NumPy API 的第一组互操作性特性允许在可能的情况下将外部对象视为 NumPy 数组.当 NumPy 函数遇到外部对象时,它们将尝试(按顺序):
缓冲区协议,在 in the Python C-API documentation 中描述.
__array_interface__协议, in this page 描述.它是 Python 缓冲区协议的前身,它定义了一种从其他 C 扩展访问 NumPy 数组内容的方法.__array__()方法,它要求任意对象将自身转换为数组.
对于缓冲区协议和 __array_interface__ 协议,对象描述其内存布局, NumPy 执行其他所有操作(如果可能,则零拷贝).如果不可能,则对象本身负责从 __array__() 返回一个 ndarray .
DLPack 是另一种以语言和设备无关的方式将外部对象转换为 NumPy 数组的协议.NumPy 不会使用 DLPack 隐式地将对象转换为 ndarray.它提供了函数 numpy.from_dlpack ,该函数接受任何实现 __dlpack__ 方法的对象,并输出一个 NumPy ndarray(通常是输入对象数据缓冲区的视图). Python Specification for DLPack 页面详细解释了 __dlpack__ 协议.
数组接口协议#
array interface protocol 定义了一种数组类对象重用彼此数据缓冲区的方式.它的实现依赖于以下属性或方法的存在:
__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__ 协议#
universal function (or ufunc for short) 是一个"矢量化"包装器,用于接受固定数量的特定输入并产生固定数量的特定输出的函数. 如果并非所有输入参数都是 ndarray,则 ufunc(及其方法)的输出不一定是 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 类型存在多种可能性时要返回的对象类型.例如,子类可以选择使用此方法将输出数组转换为子类的实例,并在将数组返回给用户之前更新元数据.
有关这些方法的更多信息,请参阅 ndarray 的子类化 和 ndarray 子类型的特定功能 .
互操作性示例#
示例:Pandas Series 对象#
考虑以下情况:
>>> import pandas as pd
>>> ser = pd.Series([1, 2, 3, 4])
>>> type(ser)
pandas.core.series.Series
现在, ser 不是 ndarray,但因为它 implements the __array_ufunc__ protocol ,我们可以像对待 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_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 作为张量数据缓冲区的视图.有关详细信息,请参见 this issue 和 __torch_function__ implementation .
另请注意,即使 torch.Tensor 不是 ndarray 的子类,我们也可以在此处看到 __array_wrap__ 的作用:
>>> 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 ( a counterpart to NumPy ndarrays )来实现 NumPy 接口的子集.
>>> import cupy as cp
>>> x_gpu = cp.array([1, 2, 3, 4])
cupy.ndarray 对象实现了 __array_ufunc__ 接口. 这使 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'>
有关详细信息,请参见 this page in the CuPy documentation for details .
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 函数,但并非全部. 有关详细信息,请参见 the CuPy documentation .
示例: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() 要求时才会计算.
有关详细信息,请参阅 the Dask array documentation 以及 scope of Dask arrays interoperability with NumPy arrays .
示例:DLPack#
一些 Python 数据科学库实现了 __dlpack__ 协议. 其中包括 PyTorch 和 CuPy . 可以在 this page of DLPack documentation 上找到实现此协议的库的完整列表.
将 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__实现的更多详细信息)