NEP 21 — 简化并明确高级索引#

作者:

Sebastian Berg

作者:

Stephan Hoyer <shoyer@google.com>

状态:

已推迟

类型:

标准轨道号

创建:

2015-08-27

摘要#

NumPy 使用其他数组对数组进行索引的“高级”索引支持是其最强大且最受欢迎的功能之一。很不幸的是,对使用多个数组索引进行高级索引的现有规则通常会让 NumPy 新用户和在许多情况下甚至是老用户感到困惑。在此,我们建议彻底修改并简化高级索引,包括两个新的“索引器”属性 oindexvindex 以方便明确索引。

背景#

现有索引操作#

NumPy 数组目前支持灵活的索引操作范围

  • 只涉及切片、整数、np.newaxis 和省略标记 (...) 的“基本”索引,例如,x[0, :3, np.newaxis] 用以选择第 0 个轴中的第一个元素,第 1 个轴中的前三个元素并最后插入一个大小为 1 的新轴。基本索引始终返回索引数组数据的视图。

  • “高级”索引(又称“花式”索引)包括通过其他数组对数组进行索引的所有情况。高级索引始终执行复制操作

    • 通过布尔数组执行“布尔”索引,例如,x[x > 0] 用以选择正元素。

    • 通过一个或多个整数数组执行“矢量化”索引,例如,x[[0, 1]] 用以选择沿第 1 个轴的前两个元素。通过多个数组,矢量化索引会使用广播规则合并沿多个维度的索引。这允许通过原始数组中的任意元素生成任意形状的结果。

    • 涉及其他高级类型任意组合的“混合”索引。这并不比矢量化索引强大,但有时用起来更方便。

为了清楚起见,我们将这些现有规则称为“旧式索引”。这只是一个高级别总结;如需了解更多详细信息,请参见下面的 NumPy 文档和示例

外层索引#

广泛有用的索引操作类别不受支持

  • “外层”或正交索引将一维数组视为切片以确定输出形状。外层索引的规则是结果应等效于独立地沿着每个维度使用整数或布尔数组进行索引,仿佛被索引数组和索引数组都是一维的。此类索引形式是许多使用其他编程语言(如 MATLAB、Fortran 和 R)的用户熟悉的。

NumPy 省略对外层索引的支持,原因是外层和矢量化规则冲突。考虑使用两个 1D 整数数组对一个 2D 数组进行索引,例如,x[[0, 1], [0, 1]]

  • 外层索引等效于使用itertools.product() 组合多个整数索引。在这种情况下,结果是另一个包含索引元素的所有组合的 2D 数组,例如,np.array([[x[0, 0], x[0, 1]], [x[1, 0], x[1, 1]]])

  • 矢量化索引相当于使用 zip() 结合多个整数索引。在这种情况下,结果为包含对角元素的一维数组,例如, np.array([x[0, 0], x[1, 1]])

对于新手 NumPy 用户来说,这种差异是一个常见的障碍。外部索引模型更容易理解,并且是对切片规则的自然概括。但是,NumPy 选择支持矢量化索引,因为它更强大。

通过使用正确的索引,始终可以用矢量化索引模拟外部索引。为了简化此操作,NumPy 包含一些实用对象和函数,如 np.ogridnp.ix_,例如, x[np.ix_([0, 1], [0, 1])]。但是,没有用于模拟完全通用/混合外部索引的实用工具,这会明确允许出现切片、整数和一维布尔阵列及整数阵列。

混合索引#

NumPy 现有的用于在同一操作中结合多种索引类型的规则非常复杂,涉及许多边缘案例。

混合索引特别容易混淆的一个原因是,乍一看,结果的运作方式如同外部索引一样具有欺骗性。回到 2D 数组的示例, x[:2, [0, 1]]x[[0, 1], :2] 都会返回轴的顺序与原始数组相同的 2D 数组。

但是,一旦引入两个或更多个非切片对象(包括整数),则应用矢量化索引规则。由数组索引引入的轴位于前面,除非所有数组索引是连续的,在这种情况下,NumPy 会推断用户“希望”它们位于何处。考虑使用形状为 (X, Y, Z) 的 3D 数组 arr 进行索引

  1. arr[:, [0, 1], 0] 的形状为 (X, 2)

  2. arr[[0, 1], 0, :] 的形状为 (2, Z)

  3. arr[0, :, [0, 1]] 的形状为 (2, Y),而不是 (Y, 2)

前两种情况与外部索引直观且一致,但最后一种情况颇为令人惊讶,即使是很多很有经验的 NumPy 用户也会感到惊讶。

涉及多个数组索引的混合情况也令人惊讶,并且只是不太成问题,因为当前的行为非常无用,因此在实践中很少遇到。当布尔数组索引与另一个布尔或整数数组混合时,布尔数组将转换为整数数组索引(等效于 np.nonzero()),然后广播。例如,对大小为 (2, 2) 的 2D 数组进行索引,如 x[[True, False], [True, False]] 会生成形状为 (1,) 的 1D 向量,而不是形状为 (1, 1) 的 2D 子矩阵。

混合索引看起来非常棘手,以至于想说它永远不应该使用。然而,要避免这种索引并不容易,因为如果索引少于索引数组的全部维数,NumPy 会隐式添加全部切片。这意味着对 x[[0, 1]]` 等 2D 数组进行索引等效于 x[[0, 1], :]。这些情况并不会令人惊讶,但它们限制了混合索引的行为。

其他 Python 数组库中的索引#

索引是一种访问多维数组数据的有用且得到广泛认可的机制,因此,科学 Python 生态系统中的许多其他库也支持数组索引也就不足为奇了。

不幸的是,NumPy 的索引规则的全部复杂性意味着,其他库在所有细微差别上复制其行为既有挑战性,也不可取。NumPy 风格索引的唯一完整实现是 NumPy 本身。此类项目包括 dask.array 和 h5py,它们以某种形式支持大多数类型的数组索引,否则会尝试确切复制 NumPy 的 API。

特别是,基于非 NumPy 的数组存储后端的向量化索引可能很难实现。相反,沿至少一个维度(外部索引的方式)按 1D 数组索引要容易得多。这导致许多库(包括 dask 和 h5py)尝试定义与外部索引等效的 NumPy 式索引的安全子集,例如仅允许沿最多一个维度对数组进行索引。然而,一般化到能用得上却很难正确地做到。例如,当前版本的 dask 和 h5py 在情况下 3 中处理混合索引时都与 NumPy 不一致。这很可能导致错误。

这些不一致性再加上实现各种索引逻辑的更广泛挑战,让人难以编写像 xarray 或 dask.array 这样的高层数组库,这些库可以互换地索引很多类型的数组存储。相比较而言,NumPy 中外部索引和向量化索引的显式 API 将提供一个外部库可以可靠地仿效的模型,即使不支持每种类型的索引。

高层更改#

受多重“索引器”属性的启发,以控制 pandas 中不同类型的索引行为,我们建议

  1. 引入 arr.oindex[indices] 它允许数组索引,但使用外部索引逻辑。

  2. 引入 arr.vindex[indices] 它使用当前的“向量化”/广播逻辑,但与旧索引逻辑有以下两点区别

    • 不支持布尔索引。所有索引都必须是整数、整型数组或切片。

    • 整型索引结果维度始终是结果数组的第一个轴。不会进行转置,即使对于单个整型数组索引。

  3. 对数组进行平面索引会开始给出警告,最终在应该优先使用显式索引器的一种情况下给出错误

    • 首先,在旧索引和外部索引给出不同结果的所有情况下。

    • 之后,可能在涉及整型数组的所有情况下。

这些约束足以使索引与期望值基本一致,并在使用 oindex 时提供不太令人惊讶的学习曲线。

请注意,这里提到的一切都同时适用于赋值和订阅。

理解这些细节不容易。讨论中的示例部分给出了代码示例。而更简单的激励性示例为一般思想提供了一些激励使用案例,对于不熟悉高级索引的人来说可能是不错的起点。

详细描述#

建议规则#

从以上提到的三个问题中可以得出一些对 NumPy 的期望

  1. 应当有一个突出外部/正交索引方法,如 arr.oindex[indices]

  2. 考虑矢量化/花式索引有多令人迷惑,应当有可能使其更显式(例如 arr.vindex[indices])。

  3. 新的 arr.vindex[indices] 方法不会与花式索引令人迷惑的转置规则绑定,例如,在单一高级索引的简单情况下需要转置规则。因此,不应执行转置。整数数组索引创建的轴始终插入在前部,即使是一个索引也是如此。

  4. 布尔索引是概念上的外部索引。对传统索引的方式和其他高级索引进行广播通常无用且无法定义。因此,预期希望加上广播行为的“nonzero”的用户手动执行此操作。因此,vindex 不需要支持布尔索引数组。

  5. 应当实现 arr.legacy_index 属性,以支持传统索引。这提供了一种使用传统索引更新现有代码库的简单方法,该方法可以更轻松地弃用简单的索引行为。较长的名称 legacy_index 是经过特意选择的,以使其显示清晰并阻止在新的代码中使用它。

  6. 对于歧义情况,简单索引 arr[...] 应当返回一个错误。从一开始,这可能表示当 arr[ind]arr.oindex[ind] 返回不同的结果时给出的弃用警告。这包括针对多个整数数组的矢量化索引的每种用法。由于转置行为,这意味着 ``arr[0, :, index_arr]`` 会被弃用,但 arr[:, 0, index_arr] 暂时不会被弃用。

  7. 为确保覆盖索引的 ndarray 的现有子类不会无意中恢复为索引属性的默认行为,这些属性应当具有显式检查,以便在 __getitem____setitem__ 被覆盖时禁用这些属性。

与简单索引不同,新的索引属性明确针对更高维度的索引,应执行一些其他更改

  • 索引属性将强制确切的维度和索引匹配。这意味着将不会添加隐式省略号 (...)。除非省略号存在,否则索引表达式将只适用于具有特定维度数的数组。这使得表达式更加明确并且防止数组维度错误。对内置 Python 序列的“鸭子类型”兼容性不应该有任何影响,因为 Python 序列仅支持有限形式的带有整数和切片的“基本索引”。

  • 当前普通索引允许对非元组使用多维索引,如 arr[[slice(None), 2]]。这会产生一些不一致性,因此索引属性应该只允许使用普通 python 元组。它是否应该用于普通索引则是不同的问题。

  • 新属性不应该使用 getitem 来实现 setitem,因为它是一种小技巧,而且对向量化索引没有用。(尚未实现)

待解决问题#

  • 名称 oindexvindexlegacy_index 在编写本文时只是建议,NumPy 用于 oindex 等内容的另一个名称是 np.ix_。另请参见下文。

  • oindexvindex 始终可以返回副本,即使没有发生数组操作。允许视图返回的一个论点是,这样 oindex 可以用作通用索引替换。但是,有一个返回副本的论点。可能出现 arr.vindex[array_scalar, ...],其中 array_scalar 应该是一个 0-D 数组,但不是,因为 0-D 数组往往被转换。复制始终“修复”这种可能的冲突。

  • 转换普通索引器的最终状态不会固定在该 PEP 中。例如,将来 arr[index]` 有可能等效于 arr.oindex。由于这种改变需要数年的时间,此时做出具体决策似乎没有必要。

  • 为了不破坏或强制修复现有代码库,可以无限期推迟对普通索引的提议更改或不予采纳。

备用名称#

建议的可能名称(将会添加更多建议)。

正交

oindex

oix

向量化

vindex

vix

传统

legacy_index

l/findex

子类#

这些变化可能会给子类带来一些问题。针对这种情况,有一些可能的解决方案。对于大多数子类(不提供 __getitem____setitem__ 的子类),特殊属性应正常工作。确实提供它的子类必须进行相应更新,并且最好不要对 oindexvindex 进行子类化。

所有子类都将继承这些属性,但是,这些属性上 __getitem__ 的实现应测试 subclass.__getitem__ is ndarray.__getitem__。如果不满足此条件,则子类对索引处理有特殊方法,此时应引发 NotImplementedError,且要求索引属性也必须明确覆盖。同样,对 __setitem__ 的实现应检查 __setitem__ 是否被覆盖。

另一个问题是如何促进实施这些特殊属性。另外,还有一个奇怪的功能,对非高级索引调用 __setitem__ 将调用 __getitem__。最好为新属性避免此情况,但另一方面,这么做可能会导致更大的混淆。

为了促进实施,我们可以提供与 operator.itemgetteroperator.setitem 类似的函数来处理属性。可以提供 mixin 来帮助实施。这些改进对于最初的实施不是必不可少的,因此已留待今后处理。

实施#

实施将从编写可以通过 arr.oindexarr.vindexarr.legacy_index 访问的特殊索引对象开始,以允许进行这些索引操作。此外,还需要开始弃用那些不存在歧义的普通索引操作。另外,NumPy 代码库需要使用新属性,并且需要调整测试。

向后兼容性#

作为新功能,新的 vindexoindex 属性不会出现任何与旧版兼容性的问题。

为了尽可能地促进向后兼容性,我们预计传统索引行为将经历一个漫长的弃用周期,并建议采用新的 legacy_index 属性。

可能出现一些与没有专门实现新方法的子类相关的向前兼容性问题。

替代选项#

NumPy 可能选择不提供这些不同类型的索引方法,或者选择仅通过特定函数提供,而不是通过上述建议的符号。

我们认为新函数不是一个好的替代选项,因为与函数相比,索引符号 [] 在 Python 中提供了一些语法优势(即,直接创建切片对象)。

更合理的替代选项是编写新的包装对象,使用函数而不是方法进行备用索引(例如,np.oindex(arr)[indices],而不是 arr.oindex[indices])。在功能上,这将是等效的,但索引是一个如此常见的操作,我们认为直接在 ndarray 对象上实现它非常重要,值得最小化语法。无论目前正在努力让 NumPy 函数更易于覆盖 [2],索引属性还定义了副本更方便的清晰界面,可供备用阵列实现使用。

讨论#

最初关于矢量化索引和外/正交索引的讨论始于 NumPy 邮件列表

可在针对此 NEP 的原始拉取请求中找到部分讨论

可在以下位置找到索引操作的 Python 实现

示例#

在很多情况下很难理解各种索引类型,希望这些示例能提供更多见解。请注意,它们都是根据形状来划分的。在这些示例中,所有原始维度都有 5 个或更多元素,高级索引会插入较小的维度。如果没有关于高级索引的 NumPy 1.9 的工作知识,可能难以理解这些示例。

示例数组

>>> arr = np.ones((5, 6, 7, 8))

传统花式索引#

请注意,可以使用 arr.legacy_index 实现相同的结果,但在这种情况下,“将来发生的错误”仍有效。

单个索引经过转置(所有索引类型相同)

>>> arr[[0], ...].shape
(1, 6, 7, 8)
>>> arr[:, [0], ...].shape
(5, 1, 7, 8)

多个索引经过转置如果是连续的

>>> arr[:, [0], [0], :].shape  # future error
(5, 1, 8)
>>> arr[:, [0], :, [0]].shape  # future error
(1, 5, 7)

值得注意的是,标量本意义上的整数数组索引(并与其他高级索引进行广播

>>> arr[:, [0], 0, :].shape
(5, 1, 8)
>>> arr[:, [0], :, 0].shape  # future error (scalar is "fancy")
(1, 5, 7)

单个布尔索引可在多个维度(尤其是整个数组)上起作用。它必须与维度匹配(自 1.10 起)。否则,布尔索引与(多个连续的)整数数组索引相同

>>> # Create boolean index with one True value for the last two dimensions:
>>> bindx = np.zeros((7, 8), dtype=np.bool_)
>>> bindx[0, 0] = True
>>> arr[:, 0, bindx].shape
(5, 1)
>>> arr[0, :, bindx].shape
(1, 6)

与标量无关的任何组合都是令人困惑的,例如

>>> arr[[0], :, bindx].shape  # bindx result broadcasts with [0]
(1, 6)
>>> arr[:, [0, 1], bindx].shape  # IndexError

外部索引#

多个索引是“正交”的,其结果轴插入到相同位置(它们不会广播)

>>> arr.oindex[:, [0], [0, 1], :].shape
(5, 1, 2, 8)
>>> arr.oindex[:, [0], :, [0, 1]].shape
(5, 1, 7, 2)
>>> arr.oindex[:, [0], 0, :].shape
(5, 1, 8)
>>> arr.oindex[:, [0], :, 0].shape
(5, 1, 7)

布尔索引结果始终插入到索引所在的位置

>>> # Create boolean index with one True value for the last two dimensions:
>>> bindx = np.zeros((7, 8), dtype=np.bool_)
>>> bindx[0, 0] = True
>>> arr.oindex[:, 0, bindx].shape
(5, 1)
>>> arr.oindex[0, :, bindx].shape
(6, 1)

没有任何改变,同时存在其他高级索引

>>> arr.oindex[[0], :, bindx].shape
(1, 6, 1)
>>> arr.oindex[:, [0, 1], bindx].shape
(5, 2, 1)

矢量化/内部索引#

多个索引像高级索引一样广播并作为一个个体进行迭代,但新轴始终插入到前面

>>> arr.vindex[:, [0], [0, 1], :].shape
(2, 5, 8)
>>> arr.vindex[:, [0], :, [0, 1]].shape
(2, 5, 7)
>>> arr.vindex[:, [0], 0, :].shape
(1, 5, 8)
>>> arr.vindex[:, [0], :, 0].shape
(1, 5, 7)

布尔索引结果始终插入到索引所在的位置,就像在 oindex 中一样,这取决于它们对其所操作轴的具体程度

>>> # Create boolean index with one True value for the last two dimensions:
>>> bindx = np.zeros((7, 8), dtype=np.bool_)
>>> bindx[0, 0] = True
>>> arr.vindex[:, 0, bindx].shape
(5, 1)
>>> arr.vindex[0, :, bindx].shape
(6, 1)

但是其他高级索引再次转置到前面

>>> arr.vindex[[0], :, bindx].shape
(1, 6, 1)
>>> arr.vindex[:, [0, 1], bindx].shape
(2, 5, 1)

激励示例#

设想一个数据采集软件存储着 D 通道,以及随时间存储的 N 个数据点。她将其存储到形状为 (N, D) 的数组中。在数据分析期间,我们需要提取一组通道,例如计算它们的平均值。

此数据可以使用伪造

>>> arr = np.random.random((100, 10))

现在,人们可能会记住使用整数数组索引并找到正确的代码

>>> group = arr[:, [2, 5]]
>>> mean_value = arr.mean()

但是,假定有一些特定时间点(数据的第一个维度)需要特别考虑。这些时间点是已知的,由以下内容给出

>>> interesting_times = np.array([1, 5, 8, 10], dtype=np.intp)

现在,要提取它们,我们可以尝试修改前面的代码

>>> group_at_it = arr[interesting_times, [2, 5]]
IndexError: Ambiguous index, use `.oindex` or `.vindex`

此类错误将在指出需要阅读索引文档。这应该说清楚,oindex 的行为更像切片。因此,在不同的方法中,这是显而易见的选择(目前,这是一个形状不匹配的情况,但也可能提及 oindex

>>> group_at_it = arr.oindex[interesting_times, [2, 5]]

当然,也可以使用 vindex,但是实现正确的方式却并不那么明显!

>>> reshaped_times = interesting_times[:, np.newaxis]
>>> group_at_it = arr.vindex[reshaped_times, [2, 5]]

人们可能会发现,例如我们的数据在某些地方已损坏。因此,我们需要通过零(或任何其他内容)替换这些时间的这些值。例如,第一列可能给出所需信息,从而通过记住布尔索引可以轻松更改值

>>> bad_data = arr[:, 0] > 0.5
>>> arr[bad_data, :] = 0  # (corrupts further examples)

然而,列可能需要以更为独立的方式(但按组)处理,而 oindex 属性非常适用

>>> arr.oindex[bad_data, [2, 5]] = 0

请注意,使用传统的 fancy 索引很难实现这一点。唯一的方法是首先创建一个整数数组

>>> bad_data_indx = np.nonzero(bad_data)[0]
>>> bad_data_indx_reshaped = bad_data_indx[:, np.newaxis]
>>> arr[bad_data_indx_reshaped, [2, 5]]

在任何情况下,我们可以只使用 oindex 来完成所有这些,而不会遇到任何问题或因高级索引的复杂性而感到困惑。

但有些新功能已添加到数据采集。不同传感器必须根据时间来使用。让我们假设我们已经创建了一个索引数组

>>> correct_sensors = np.random.randint(10, size=(100, 2))

其中列出了每次用于 (N, 2) 数组中的两个正确传感器的列表

实现这一目标的第一次尝试可能是 arr[:, correct_sensors],但不起作用。应该很快就会明白分片无法实现所需的效果。但希望用户会记住,作为高级索引中更强大更灵活的方法,存在 vindex。如果尝试随机使用 vindex,可能会对以下方面感到困惑

>>> new_arr = arr.vindex[:, correct_sensors]

这两个既不相同,也不正确(请参见正交规则)!这是因为切片在 vindex 中仍然起作用。然而,希望阅读文档和示例后,能很快找到所需的解决方案

>>> rows = np.arange(len(arr))
>>> rows = rows[:, np.newaxis]  # make shape fit with correct_sensors
>>> new_arr = arr.vindex[rows, correct_sensors]

此时我们已经离开了 oindex 的直截了当的世界,但可以从数组中随机选择任何元素。请注意,在最后一个示例中,如在 Related Questions 部分中提到的方法可能会更直截了当。但这种方法更加灵活,因为 rows 不必是一个简单的 arange,但可以是 interesting_times

>>> interesting_times = np.array([0, 4, 8, 9, 10])
>>> correct_sensors_at_it = correct_sensors[interesting_times, :]
>>> interesting_times_reshaped = interesting_times[:, np.newaxis]
>>> new_arr_it = arr[interesting_times_reshaped, correct_sensors_at_it]

如果您将 L 实验合并到形状为 (L, N, D) 的数组中,那么现在将会出现真正复杂的情况。但对于 oindex 来说,这不会导致惊讶。由于 vindex 更加强大,在这种情况下肯定会出现一些困惑,但也几乎涵盖了所有可能性。

参考和脚注#