如何理解NumPy中的原地操作(例如`+=`)?

23

基本问题是:在执行a[i] += b时,底层会发生什么?

给定以下内容:

import numpy as np
a = np.arange(4)
i = a > 0
i
= array([False,  True,  True,  True], dtype=bool)

我明白:

  • a[i] = xa.__setitem__(i, x)相同,都是直接将x赋值给a中由i指定的项
  • a += xa.__iadd__(x)相同,都是在原地进行加法操作

但是当我执行以下操作时会发生什么:

a[i] += x

具体来说:

  1. 这是否与 a[i] = a[i] + x 相同?(后者不是原地操作)
  2. 在这种情况下,如果 i 是:
    • 一个 int 索引,或者
    • 一个 ndarray,或者
    • 一个 slice 对象

背景

我开始研究这个问题的原因是我在处理重复索引时遇到了一个不直观的行为:

a = np.zeros(4)
x = np.arange(4)
indices = np.zeros(4,dtype=np.int)  # duplicate indices
a[indices] += x
a
= array([ 3.,  0.,  0.,  0.])

这个问题的链接中包含了有关重复索引的更有趣的信息。


我认为你的背景示例没有问题。它需要在内部迭代所有值。如果在一个就地操作中将值分配给数组,其中所有索引都相同,则最后一个将覆盖其余部分。这显然是发生的事情(x 的最后一个值覆盖了所有其他值)。 - Henry Gomersall
1
哦,我明白问题了。如果在内部实际上是将操作结果写回到内存中,那么您期望看到x中所有值的总和。嗯... - Henry Gomersall
@HenryGomersall,没错。 - shx2
4个回答

21

你需要意识到的第一件事是,a += x并不完全等同于 a.__iadd__(x),而是等同于a = a.__iadd__(x)。请注意文档明确说明就地操作符返回其结果,这不必是self(尽管在实践中通常如此)。这意味着a[i] += x简单地映射为:

a.__setitem__(i, a.__getitem__(i).__iadd__(x))
所以,从技术上讲,加法是就地进行的,但仅在临时对象上。然而,与调用__add__相比,仍可能少创建一个临时对象。

2
谢谢你的提示!运行a.iadd(a)有什么危险吗? - ntg

6

虽然我不知道底层发生了什么,但是在NumPy数组和Python列表中执行就地操作将返回相同的引用,这可能会导致混淆的结果,当它们被传递到函数中时。

从Python开始

>>> a = [1, 2, 3]
>>> b = a
>>> a is b
True
>>> id(a[2])
12345
>>> id(b[2])
12345

...其中12345是内存中a[2]值的位置的唯一标识符,与b[2]相同。

因此,ab引用内存中的同一列表。现在尝试对列表中的一个项目进行原地加法操作。

>>> a[2] += 4
>>> a
[1, 2, 7]
>>> b
[1, 2, 7]
>>> a is b
True
>>> id(a[2])
67890
>>> id(b[2])
67890

因此,在列表中就地添加项目仅更改了索引为2的项的值,但ab仍然引用相同的列表,尽管列表中的第三个项目被重新分配给新值7。重新分配解释了为什么如果a = 4b = a是整数(或浮点数)而不是列表,则a += 1将导致a被重新分配,然后ba将是不同的引用。但是,如果调用列表添加,例如:a += [5] 对于引用相同列表的ab,它不会重新分配a;它们都将被追加。

现在看看NumPy

>>> import numpy as np
>>> a = np.array([1, 2, 3], float)
>>> b = a
>>> a is b
True

这些都是相同的参考文献,Python中与列表相同的就地操作符似乎具有相同的效果:

>>> a += 4
>>> a
array([ 5.,  6.,  7.])
>>> b
array([ 5.,  6.,  7.])

在原地添加ndarray会更新引用。这与调用numpy.add不同,后者会在新引用中创建副本。
>>> a = a + 4
>>> a
array([  9.,  10.,  11.])
>>> b
array([ 5.,  6.,  7.])

借用引用的原地操作

我认为这里存在的危险是,如果将引用传递到不同的作用域。

>>> def f(x):
...     x += 4
...     return x

参数参考 x 被传递到作用域 f 中,它不会进行复制并且实际上会改变该参考值,并将其传递回来。
>>> f(a)
array([ 13.,  14.,  15.])
>>> f(a)
array([ 17.,  18.,  19.])
>>> f(a)
array([ 21.,  22.,  23.])
>>> f(a)
array([ 25.,  26.,  27.])

同样适用于Python列表:
>>> def f(x, y):
...     x += [y]

>>> a = [1, 2, 3]
>>> b = a
>>> f(a, 5)
>>> a
[1, 2, 3, 5]
>>> b
[1, 2, 3, 5]

我认为这种操作容易引起混淆,有时也很难调试。因此,我只会在当前作用域中使用原地操作符,并且会谨慎处理借用引用。


6
实际上这与numpy无关。在Python中,没有“就地设置/获取项”,这些东西等同于a[indices] = a[indices] + x。知道了这一点,就很容易理解发生了什么。(编辑:正如lvc所写,实际上右侧是原地的,因此如果那是合法语法,则为a[indices] =(a[indices] + = x),虽然具有类似的效果)。
当然,a += x实际上是原地的,通过将a映射到np.addout参数。
这已经讨论过了,numpy无法对此做任何事情。虽然有一个想法,即使用np.add.at(array, index_expression, x)来允许这样的操作。

2
NumPy 1.8.0 版本中新增了 ufuncs 的 @ 运算符降维功能。 - jtaylor
2
@seberg:你确定a[:] = a[:]+x和a+=x是一样的吗?它们确实产生相同的结果,但除非我犯了错误,否则后者对于大数组来说明显更快。 - gebbissimo

2

正如Ivc所解释的那样,Python中没有原地添加元素的方法,因此在底层它使用__getitem____iadd____setitem__。以下是一种经验性观察这种行为的方法:

import numpy

class A(numpy.ndarray):
    def __getitem__(self, *args, **kwargs):
        print("getitem")
        return numpy.ndarray.__getitem__(self, *args, **kwargs)
    def __setitem__(self, *args, **kwargs):
        print("setitem")
        return numpy.ndarray.__setitem__(self, *args, **kwargs)
    def __iadd__(self, *args, **kwargs):
        print("iadd")
        return numpy.ndarray.__iadd__(self, *args, **kwargs)

a = A([1,2,3])
print("about to increment a[0]")
a[0] += 1

它打印出来了

about to increment a[0]
getitem
iadd
setitem

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接