NEP 22 — NumPy 数组的“鸭子类型”——高层概述#
- 作者:
Stephan Hoyer <shoyer@google.com>, Nathaniel J. Smith <njs@pobox.com>
- 状态:
最终
- 类型:
信息性
- 创建时间:
2018-03-22
- 解决时间:
https://mail.python.org/pipermail/numpy-discussion/2018-September/078752.html
摘要#
我们勾勒出了 NumPy 如何处理“鸭子数组”的高层愿景。这是一份信息类 NEP,它不规定任何具体实现的详细信息。简而言之,我们提议开发一系列新的协议,用于定义具有与 NumPy 匹配的高层 API 的多维数组实现。
详细描述#
传统上,NumPy 的 ndarray 对象提供了两样东西:一个用于对同质类型、任意维度、数组结构数据进行表达式操作的高层 API,以及一个基于内存中跨步存储的 API 具体实现。该 API 功能强大、相当通用,并且在科学 Python 栈中得到广泛应用。另一方面,具体实现适用于多种用途,但也存在局限性:随着数据集的增长,NumPy 在各种新环境中的应用也日益广泛,内存中跨步存储策略越来越不适用,用户发现他们需要稀疏数组、惰性求值数组(如 dask)、压缩数组(如 blosc)、存储在 GPU 内存中的数组、存储在 Arrow 等替代格式中的数组,等等——但用户仍希望使用熟悉的 NumPy API 来处理这些数组,并以最小(理想情况下为零)的移植开销重用现有代码。作为一个工作中的简写,我们称之为“鸭子数组”,这是类比 Python 的“鸭子类型”:“鸭子数组”是一个 Python 对象,它在“叫声”上像一个 NumPy 数组(即具有相同或相似的 Python API),但它不共享 C 级实现。
本 NEP 不提议对 NumPy 或其他项目进行任何具体更改;相反,它概述了我们希望如何扩展 NumPy 以支持一个强大生态系统,该生态系统实现并依赖于其高层 API。
术语#
“鸭子数组”目前可以作为一个占位符,但它相当专业,可能会让新用户感到困惑,所以我们可能想为实际的 API 函数选择一个更合适的名称。不幸的是,“类数组”(array-like)已经被用于“任何可以被强制转换为数组的东西”(例如列表对象)的概念,而“任何数组”(anyarray)已经被用于“共享 ndarray 实现但具有不同语义的东西”的概念,这与鸭子数组相反(例如,np.matrix 是一个“anyarray”,但不是一个“duck array”)。这是一个经典的“自行车棚”问题,所以目前我们只是使用“鸭子数组”。不过,一些可能的选择包括:arrayish、pseudoarray、nominalarray、ersatzarray、arraymimic,等等。
总体方法#
总的来说,鸭子数组支持需要遍历 NumPy 提供的每一个 API 函数,并找出如何将其扩展以与鸭子数组对象一起使用。在某些情况下这很容易(例如,ndarray 本身的 métodos/attributes);在其他情况下则更难。以下是我们到目前为止发现的有用原则:
原则 1:专注于“完整”鸭子数组,但不排除“部分”鸭子数组#
我们可以区分两类:
“完整”鸭子数组,它们旨在完全实现 np.ndarray 的 Python 级 API,并且几乎在 np.ndarray 起作用的任何地方都能起作用。
“部分”鸭子数组,它们有意只实现 np.ndarray API 的一个子集。
完整鸭子数组,嗯,很无趣。它们具有与 ndarray 完全相同的语义,差异仅限于关于数据实际存储方式的幕后决定。那些热衷于使 NumPy 更具扩展性的人,也毫不意外地热衷于更改或扩展 NumPy 的语义。因此,关于如何最好地支持部分鸭子数组的讨论很多。我们自己也曾犯过这样的错误。
然而,到目前为止,我们认为最好的通用策略是主要将我们的精力集中在支持完整鸭子数组上,而只在我们必须确保不无缘无故地排除它们的情况下才考虑部分鸭子数组。
为什么专注于完整鸭子数组?有几个原因:
首先,有很多清晰的用例。完整鸭子数组接口的潜在消费者包括几乎所有使用 NumPy 的包(scipy、sklearn、astropy 等),特别是提供数组包装类的包,如 xarray 和 dask.array,它们处理多种类型的数组。完整鸭子数组接口的潜在实现者包括:分布式数组、稀疏数组、掩码数组、带单位的数组(除非它们切换到使用 dtype)、标记数组等。清晰的用例可以带来良好且相关的 API。
其次,安娜·卡列尼娜原则在此适用:完整鸭子数组都相似,但每个部分鸭子数组都有其独特之处。
xarray.DataArray大部分是鸭子数组,但具有不兼容的广播语义。xarray.Dataset将多个数组包装在一个对象中;它仍然实现了部分数组接口,如__array_ufunc__,但肯定不是全部。pandas.Series具有与 NumPy 行为相似的方法,但具有独特的空值跳过行为。scipy 的
LinearOperator支持矩阵乘法,仅此而已。h5py 和类似的数组存储访问库的对象支持类似 NumPy 的切片和转换为完整数组,但不支持计算。
某些类可能与 ndarray 相似,但不支持完整的索引语义。
等等。
尽管我们尽了最大努力,但我们没有找到任何清晰、独特的方法来将 ndarray API 分割成相关的类型层次结构来捕捉这些区别;事实上,不太可能任何一个人都能理解所有这些区别。这一点很重要,因为我们需要为大量的 API 添加鸭子数组支持(无论是在 NumPy 中还是在所有依赖 NumPy 的项目中!)。根据定义,这些 API 对 ndarray 已经起作用,所以希望让它们对完整鸭子数组起作用不至于太难,因为根据定义,完整鸭子数组的行为就像 ndarray。我们需要遍历每个函数并确定它所需的 ndarray API 的确切子集,然后弄清楚哪些部分数组类型可以/应该支持它,这将非常麻烦。一旦我们让完整鸭子数组正常工作,我们就可以稍后返回并根据需要进一步完善所需的 API。专注于完整鸭子数组使我们能够立即开始取得进展。
将来,识别特定鸭子数组用例并标准化针对这些用例的更窄接口可能会很有用。例如,拥有一个标准的“数组加载器”接口,h5py、netcdf、pydap、zarr 等文件访问库都实现它,以便于在这些库之间切换,这可能会很有意义。但这可以在我们进行过程中完成,而且不一定需要 NumPy 开发者参与。关于这可能是什么样子的示例,请参阅 dask.array.from_array 的文档。
原则 2:利用鸭子类型#
ndarray 具有非常大的 API 表面积。
In [1]: len(set(dir(np.ndarray)) - set(dir(object)))
Out[1]: 138
这还只是一个巨大的**低估**,因为 NumPy 和其他库中还有许多独立的函数目前使用 NumPy C API,因此只能处理 ndarray 对象。在类型理论中,一个类型由你可以对一个对象执行的操作来定义;因此,ndarray 的实际类型不仅包括其方法和属性,还包括**所有**这些函数。为了使鸭子数组取得成功,它们需要实现 ndarray API 的很大一部分——但不是全部。(例如,dask.array.Array 不提供与 ndarray.ptp 方法等价的功能,大概是因为没有人注意到或关心它的缺失。但这似乎并没有阻止人们使用 dask。)
这意味着,现实情况是,我们无法指望预先定义整个鸭子数组 API,或者任何人都能一次性实现所有内容;这将是一个渐进的过程。这也意味着,即使是所谓的“完整”鸭子数组接口在其边界上也有些模糊;np.ndarray API 的某些部分是鸭子数组不必实现的,但我们并不完全确定是哪些部分。
最终,它并不真正取决于 NumPy 开发人员来定义什么构成或不构成鸭子数组。如果我们希望 scikit-learn 函数也能处理 dask 数组(例如),那么这将需要这两个项目之间的协商来发现不兼容之处,当发现不兼容之处时,将由他们协商谁应该改变以及如何改变。NumPy 项目可以提供技术工具和一般性建议来帮助解决这些分歧,但我们不能强迫一个或另一个小组对任何给定的错误负责。
因此,尽管我们专注于“完整”鸭子数组,但我们**不**尝试定义规范性的“数组 ABC”——也许有一天它会有用,但现在不是。并且作为一个方便的副作用,缺乏规范性定义为部分鸭子数组留下了实验的空间。
但是,我们在下面为鸭子数组的实现者和使用者提供了一些更详细的建议。
原则 3:专注于协议#
历史上,NumPy 在与第三方对象进行互操作方面取得了巨大的成功,这得益于定义了**协议**,例如 __array__(要求任意对象将其自身转换为数组)、__array_interface__(Python 缓冲区协议的前身)和 __array_ufunc__(允许第三方对象支持 ufuncs,如 np.exp)。
NEP 16 采用了不同的方法:我们需要一个鸭子数组版本的 asarray,并提议通过定义一个允许实现新的 AbstractArray ABC 的对象的 asarray 版本来做到这一点。如上所述,我们现在认为尝试定义 ABC 有其他原因。但当 NEP 在邮件列表中讨论时,我们意识到即使就其本身而言,这个想法也不是那么好。更好的方法是定义一个可以在任意对象上调用的**方法**,要求它将其自身转换为鸭子数组,然后定义一个调用此方法的 asarray 版本。
这严格来说功能更强大:如果一个对象已经是鸭子数组,它可以简单地 return self。它允许更正确的语义:NEP 16 假设 asarray(obj, dtype=X) 与 asarray(obj).astype(X) 相同,但事实并非如此。它支持更多用例:如果 h5py 支持稀疏数组,它可能希望提供一个本身不是稀疏数组的对象,但可以自动转换为稀疏数组。请参阅 NEP <XX,待写> 获取完整详细信息。
协议方法也更符合核心 Python 约定:例如,请参阅 __iter__ 方法将对象强制转换为迭代器,或 __index__ 协议进行安全的整数强制转换。最后,专注于协议为部分鸭子数组敞开了大门,它们可以选择并参与协议的子集,每个协议都具有明确定义的语义。
结论:协议是一个非常棒的想法——让我们做得更多。
原则 4:在可能的情况下重用现有方法#
尝试定义 ndarray 方法的清理版本,并具有更简洁的接口以方便实现,这是很有诱惑力的。例如,__array_reshape__ 可以删除 reshape 接受的一些奇怪参数,而 __array_basic_getitem__ 可以删除 NumPy 高级索引的**所有奇怪的边缘情况**。
但如上所述,我们并不真正知道我们需要哪些 API 来进行 ndarray 的鸭子类型。我们不可避免地会得到一个很长的新的特殊方法列表。相比之下,像 reshape 和 __getitem__ 这样的现有方法有一个优势,那就是它们已经被使用/测试了很长时间(在使用鸭子数组的库中),而且实际上,任何严肃的鸭子数组类型都必须实现它们。
原则 5:让做正确的事情变得容易#
让鸭子数组良好运行将是一项社区工作。文档有帮助,但只能在一定程度上。我们希望让实现正确运作的鸭子数组变得容易。
NumPy 可以提供帮助的一种方式是提供混入类,以便一次性实现大组相关功能。NDArrayOperatorsMixin 是一个很好的例子:它允许通过 __array_ufunc__ 方法隐式实现算术运算符。它还不完整,我们还需要更多这样的辅助工具(例如,用于归约)。
(我们最初认为这些混入类的重要性可能是一个提供数组 ABC 的论据,因为这是现代 Python 中混入的标准方法。但在 NEP 16 的讨论中,我们意识到部分鸭子数组在某些情况下也想利用这些混入类,所以即使我们有一个数组 ABC,混入类仍然需要某种独立的存在。所以,就别提那个论点了。)
暂定的鸭子数组指南#
总的来说,使用鸭子数组的库应该坚持最低要求,而实现鸭子数组的库应该提供尽可能完整的 API。这将确保最大程度的兼容性。例如,用户应该优先使用 .transpose() 而不是 .swapaxes()(后者可以基于 transpose 实现),但鸭子数组作者最好同时实现两者。
如果你试图实现一个鸭子数组,那么你应该努力实现所有内容。你肯定需要 .shape、.ndim 和 .dtype,但你的 dtype 属性也应该是 numpy.dtype 对象,奇怪的花哨索引边缘情况最好也能工作,等等。只有与 NumPy 特定的 np.ndarray 实现相关的细节(例如 strides、data、view)明确不在范围内。
未来计划(一个非常粗略的草图)#
到目前为止讨论的提案——__array_ufunc__ 和某种 asarray 协议——显然是必要但不充分的,无法实现完整的鸭子类型支持。我们预计需要额外的协议来支持(至少)以下功能:
**连接**鸭子数组,这将由其他数组组合方法(如 stack/vstack/hstack)在内部使用。连接的实现将需要在数组参数列表中进行协商。我们预计使用像
__array_ufunc__这样的__array_concatenate__协议来代替多重分派。**Ufunc-like 函数**,目前不是 ufuncs。许多 NumPy 函数,如 median、percentile、sort、where 和 clip,可以写成广义 ufuncs,但目前还不是。这些函数要么应该写成 ufuncs,要么我们应该考虑添加另一个通用的包装机制,它在功能上类似于 ufuncs,但对实现方式的保证较少。
使用鸭子数组的**随机数生成**,例如
np.random.randn()。例如,我们可能希望添加新的 API,如random_like(),用于生成具有匹配形状**和**类型的数组——尽管我们需要查看一些真实示例来了解什么会有帮助。**其他杂项函数**,如
np.einsum、np.zeros_like和np.broadcast_to,它们不属于上述任何类别。**检查鸭子数组的可变性**,这意味着它们支持使用
__setitem__进行赋值,以及 ufuncs 的 `out` 参数。许多其他方面不错的鸭子数组不易变(例如,因为它们使用了某种稀疏或压缩存储,或者位于只读共享内存中),事实证明,经常使用的代码(如np.mean的默认实现)需要检查这一点(以决定它是否可以重用临时数组)。
我们故意在此不详细说明如何添加对这些类型鸭子数组的支持。这些将是未来 NEP 的主题。
版权#
本文档已置于公共领域。