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 不一致的方式处理上述情况 3 中的混合索引。这很可能导致错误。

除了实现每种类型的索引逻辑的更广泛挑战之外,这些不一致之处使得编写像 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 维数组往往会被转换。复制始终可以“修复”这种可能的 inconsistencies。

  • 将普通索引转换成的最终状态在本 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的直接世界,但可以从数组中随机挑选任何元素。请注意,在最后一个示例中,例如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功能更强大,在这种情况下肯定会造成一些混淆,但也几乎涵盖了所有情况。

参考文献和脚注#