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]] 用于沿第一个轴选择前两个元素。对于多个数组,向量化索引使用广播规则来组合沿多个维度的索引。这允许从原始数组中生成任意形状和任意元素的结果。

    • “混合”索引涉及其他高级索引类型的任意组合。其功能不比向量化索引强大,但有时更方便。

为清楚起见,我们将这些现有规则称为“旧版索引”。这只是一个高级概述;有关更多详细信息,请参阅 NumPy 文档和下面的《示例》。

外部索引#

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

  • “外部”或正交索引将一维数组等同于切片来确定输出形状。外部索引的规则是,其结果应等同于使用整数或布尔数组沿每个维度独立索引,就像被索引数组和索引数组都是一维的。这种索引形式对于许多使用 MATLAB、Fortran 和 R 等其他编程语言的用户来说很熟悉。

NumPy 忽略对外部索引支持的原因是外部索引和向量化索引的规则冲突。考虑使用两个一维整数数组索引一个二维数组,例如 x[[0, 1], [0, 1]]

  • 外部索引等同于使用 itertools.product() 组合多个整数索引。在这种情况下,结果是另一个二维数组,包含所有被索引元素的组合,例如 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 现有的在同一操作中组合多种索引类型的规则相当复杂,涉及许多边缘情况。

混合索引特别令人困惑的一个原因是,乍一看其结果看似与外部索引相似。回到我们二维数组的例子,x[:2, [0, 1]]x[[0, 1], :2] 都返回二维数组,其轴的顺序与原始数组相同。

然而,一旦引入两个或更多非切片对象(包括整数),向量化索引规则就会生效。数组索引引入的轴位于前面,除非所有数组索引都是连续的,在这种情况下 NumPy 会推断用户“期望”它们在哪里。考虑索引一个形状为 (X, Y, Z) 的三维数组 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) 的二维数组,如 x[[True, False], [True, False]],会生成一个形状为 (1,) 的一维向量,而不是形状为 (1, 1) 的二维子矩阵。

混合索引看起来如此棘手,以至于人们想说它永远不应该被使用。然而,它并不容易避免,因为如果索引少于被索引数组的完整维度,NumPy 会隐式添加完整切片。这意味着索引一个二维数组,如 x[[0, 1]],等同于 x[[0, 1], :]。这些情况并不令人惊讶,但它们限制了混合索引的行为。

其他 Python 数组库中的索引#

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

不幸的是,NumPy 索引规则的完全复杂性意味着其他库要完全复制其所有细微行为既具有挑战性也非理想。唯一完全实现 NumPy 风格索引的是 NumPy 本身。这包括 dask.array 和 h5py 等项目,它们以某种形式支持大多数类型的数组索引,并试图精确复制 NumPy 的 API。

特别是,向量化索引在非基于 NumPy 的数组存储后端中实现起来可能具有挑战性。相比之下,以外部索引风格沿至少一个维度通过一维数组进行索引则更容易实现。这导致许多库(包括 dask 和 h5py)试图定义一个 NumPy 风格索引的安全子集,该子集等同于外部索引,例如,仅允许沿最多一个维度使用数组进行索引。然而,以足够通用的方式正确实现这一点是相当具有挑战性的。例如,当前版本的 dask 和 h5py 在上述第三种情况下处理混合索引时都与 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 维数组但实际不是的情况,因为 0 维数组倾向于被转换。复制总是“修复”这种可能的不一致。

  • 本 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

请注意,使用旧版花式索引来做这件事会非常困难。唯一的方法是先创建一个整数数组:

>>> 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 的直接世界,但可以从数组中随机挑选任何元素。请注意,在最后一个示例中,相关问题 部分提到的方法可能更直接。但这种方法更加灵活,因为 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 更强大,在这种情况下肯定会造成一些混淆,但也能涵盖几乎所有可能性。

参考文献和脚注#