NEP 21 — 简化和显式的高级索引#

作者:

Sebastian Berg

作者:

Stephan Hoyer <shoyer@google.com>

状态:

Deferred

类型:

标准轨道

创建时间:

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 的数组存储后端来说,实现起来可能很困难。相比之下,沿至少一个维度的 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 曾使用 np.ix_ 来表示类似 oindex 的内容。另请参见下文。

  • 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

请注意,使用旧式花式索引很难做到这一点。唯一的方法是先创建一个整数数组

>>> 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 更强大,在这种情况下肯定会引起一些困惑,但也几乎涵盖了所有可能性。

参考文献和脚注#