ndarray 的子类化#

引言#

ndarray 的子类化相对简单,但与其他 Python 对象相比,它有一些复杂之处。在本页中,我们将解释允许您对 ndarray 进行子类化的机制,以及实现子类的含义。

ndarrays 和对象创建#

ndarray 的子类化之所以复杂,是因为 ndarray 类的新的实例可以通过三种不同的方式产生。这些是

  1. 显式构造函数调用 - 如 MySubClass(params)。这是 Python 实例创建的常用方法。

  2. 视图转换 - 将现有 ndarray 转换为给定的子类

  3. 从模板创建新的实例 - 从模板实例创建新实例。示例包括从子类化数组返回切片,从 ufunc 创建返回类型以及复制数组。有关更多详细信息,请参见 从模板创建新的实例

最后两种是 ndarray 的特性——为了支持数组切片之类的事情。ndarray 子类化的复杂性是由于 numpy 必须支持后两种实例创建方法的机制造成的。

何时使用子类化#

除了 NumPy 数组子类化的额外复杂性之外,子类还会遇到意外行为,因为某些函数可能会将子类转换为基类并“忘记”与子类关联的任何附加信息。如果您使用未明确测试过的 NumPy 方法或函数,这可能会导致意外行为。

另一方面,与其他互操作性方法相比,子类化可能很有用,因为许多事情都会“正常工作”。

这意味着子类化可能是一种方便的方法,并且长期以来它也常常是唯一可用的方法。但是,NumPy 现在提供了在“与 NumPy 的互操作性”中描述的其他互操作性协议。对于许多用例,这些互操作性协议现在可能更适合或补充子类化的使用。

如果满足以下条件,子类化可能是一个不错的选择:

  • 您不太担心可维护性或除您自己以外的用户:子类实现起来会更快,并且可以根据需要添加额外的互操作性。对于用户较少的情况,可能的意外情况也不是问题。

  • 您认为子类信息被忽略或静默丢失并不成问题。一个例子是 np.memmap,其中“忘记”内存映射数据不会导致错误的结果。有时会让用户感到困惑的子类示例是 NumPy 的掩码数组。在引入它们时,子类化是唯一的实现方法。但是,今天我们可能会尝试避免子类化,而只依赖于互操作性协议。

请注意,子类作者也可能希望研究 与 NumPy 的互操作性 以支持更复杂的用例或解决意外行为。

astropy.units.Quantityxarray 是与 NumPy 良好互操作的类似数组对象的示例。Astropy 的 Quantity 是一个同时使用子类化和互操作性协议的双重方法的示例。

视图转换#

视图转换是标准的 ndarray 机制,您可以使用它来获取任何子类的 ndarray,并将其作为另一个(指定的)子类的数组视图返回。

>>> import numpy as np
>>> # create a completely useless ndarray subclass
>>> class C(np.ndarray): pass
>>> # create a standard ndarray
>>> arr = np.zeros((3,))
>>> # take a view of it, as our useless subclass
>>> c_arr = arr.view(C)
>>> type(c_arr)
<class '__main__.C'>

从模板创建新的实例#

ndarray 子类的新的实例也可以通过与 视图转换 非常相似的机制产生,当 numpy 发现它需要从模板实例创建新实例时。最明显的情况是当您获取子类化数组的切片时。例如

>>> v = c_arr[1:]
>>> type(v) # the view is of type 'C'
<class '__main__.C'>
>>> v is c_arr # but it's a new instance
False

切片是原始 c_arr 数据的视图。因此,当我们从 ndarray 获取视图时,我们将返回一个相同类的新 ndarray,它指向原始数据。

在使用 ndarrays 时,还有其他一些地方我们需要这样的视图,例如复制数组 (c_arr.copy()),创建 ufunc 输出数组(另请参见 ufunc 和其他函数的 __array_wrap__)和缩减方法(如 c_arr.mean())。

视图转换和从模板创建新的实例之间的关系#

这两条路径都使用相同的机制。我们在这里区分它们,因为它们会导致方法的不同输入。具体来说,视图转换 意味着您是从 ndarray 的任何潜在子类创建了数组类型的新的实例。从模板创建新的实例 意味着您是从预先存在的实例创建了类的新的实例,例如,允许您复制特定于子类的属性。

子类化的含义#

如果我们对 ndarray 进行子类化,我们不仅需要处理数组类型的显式构造,还需要处理 视图转换从模板创建新的实例。NumPy 有执行此操作的机制,正是这种机制使得子类化略有不规范。

ndarray 用于在子类中支持视图和从模板创建新的实例的机制有两个方面。

首先是使用 ndarray.__new__ 方法进行对象初始化的主要工作,而不是更常用的 __init__ 方法。其次是使用 __array_finalize__ 方法允许子类在从模板创建视图和新实例后进行清理。

关于 __new____init__ 的简要 Python 教程#

__new__ 是标准的 Python 方法,如果存在,则在创建类实例时会在 __init__ 之前调用它。有关更多详细信息,请参见 python __new__ 文档

例如,考虑以下 Python 代码

>>> class C:
...     def __new__(cls, *args):
...         print('Cls in __new__:', cls)
...         print('Args in __new__:', args)
...         # The `object` type __new__ method takes a single argument.
...         return object.__new__(cls)
...     def __init__(self, *args):
...         print('type(self) in __init__:', type(self))
...         print('Args in __init__:', args)

这意味着我们得到

>>> c = C('hello')
Cls in __new__: <class '__main__.C'>
Args in __new__: ('hello',)
type(self) in __init__: <class '__main__.C'>
Args in __init__: ('hello',)

当我们调用 C('hello') 时,__new__ 方法将其自己的类作为第一个参数,以及传递的参数,即字符串 'hello'。在 python 调用 __new__ 之后,它通常(见下文)调用我们的 __init__ 方法,其输出 __new__ 作为第一个参数(现在是类实例),以及传递的参数。

正如您所看到的,对象可以在 __new__ 方法或 __init__ 方法中初始化,或者两者都初始化,实际上 ndarray 没有 __init__ 方法,因为所有初始化都在 __new__ 方法中完成。

为什么使用 __new__ 而不是通常的 __init__?因为在某些情况下,对于 ndarray,我们希望能够返回某个其他类的对象。考虑以下情况

class D(C):
    def __new__(cls, *args):
        print('D cls is:', cls)
        print('D args in __new__:', args)
        return C.__new__(C, *args)

    def __init__(self, *args):
        # we never get here
        print('In D __init__')

这意味着

>>> obj = D('hello')
D cls is: <class 'D'>
D args in __new__: ('hello',)
Cls in __new__: <class 'C'>
Args in __new__: ('hello',)
>>> type(obj)
<class 'C'>

C 的定义与之前相同,但对于 D__new__ 方法返回类 C 的实例而不是 D 的实例。请注意,D__init__ 方法不会被调用。一般来说,当 __new__ 方法返回的不是其定义的类的对象的实例时,该类的 __init__ 方法不会被调用。

这就是 ndarray 的子类能够返回保留类类型的视图的方式。在获取视图时,标准的 ndarray 机制使用类似于以下内容创建新的 ndarray 对象:

obj = ndarray.__new__(subtype, shape, ...

其中 subtype 是子类。因此,返回的视图与子类相同,而不是类 ndarray

这解决了返回相同类型视图的问题,但现在我们又有了新的问题。ndarray 的机制可以这样设置类,在其获取视图的标准方法中,但是 ndarray __new__ 方法不知道我们在自己的 __new__ 方法中为了设置属性等等所做的事情。(旁注 - 为什么不调用 obj = subdtype.__new__(... 呢?因为我们可能没有具有相同调用签名的 __new__ 方法)。

__array_finalize__ 的作用#

__array_finalize__是NumPy提供的一种机制,允许子类处理创建新实例的各种方式。

记住,子类实例可以通过以下三种方式产生:

  1. 显式构造函数调用 (obj = MySubClass(params))。这将按通常顺序调用MySubClass.__new__,然后(如果存在)调用MySubClass.__init__

  2. 视图转换

  3. 从模板创建新的实例

我们的MySubClass.__new__方法只在显式构造函数调用时被调用,因此我们不能依赖MySubClass.__new__MySubClass.__init__来处理视图转换和从模板创建新实例。事实证明,MySubClass.__array_finalize__会在所有三种对象创建方法中被调用,因此这通常是我们进行对象创建管理的地方。

  • 对于显式构造函数调用,我们的子类需要创建一个属于自身类的新ndarray实例。实际上,这意味着我们(代码的作者)需要调用ndarray.__new__(MySubClass,...),一个类层次结构准备的super().__new__(cls, ...)调用,或者对现有数组进行视图转换(见下文)。

  • 对于视图转换和从模板创建新实例,等同于ndarray.__new__(MySubClass,...)的调用在C级别进行。

__array_finalize__接收的参数因上述三种实例创建方法而异。

以下代码允许我们查看调用序列和参数。

import numpy as np

class C(np.ndarray):
    def __new__(cls, *args, **kwargs):
        print('In __new__ with class %s' % cls)
        return super().__new__(cls, *args, **kwargs)

    def __init__(self, *args, **kwargs):
        # in practice you probably will not need or want an __init__
        # method for your subclass
        print('In __init__ with class %s' % self.__class__)

    def __array_finalize__(self, obj):
        print('In array_finalize:')
        print('   self type is %s' % type(self))
        print('   obj type is %s' % type(obj))

现在

>>> # Explicit constructor
>>> c = C((10,))
In __new__ with class <class 'C'>
In array_finalize:
   self type is <class 'C'>
   obj type is <type 'NoneType'>
In __init__ with class <class 'C'>
>>> # View casting
>>> a = np.arange(10)
>>> cast_a = a.view(C)
In array_finalize:
   self type is <class 'C'>
   obj type is <type 'numpy.ndarray'>
>>> # Slicing (example of new-from-template)
>>> cv = c[:1]
In array_finalize:
   self type is <class 'C'>
   obj type is <class 'C'>

__array_finalize__的签名是:

def __array_finalize__(self, obj):

可以看到,super调用(指向ndarray.__new__)向__array_finalize__传递了新的对象(我们自己的类,self)以及从中获取视图的对象(obj)。从上面的输出可以看出,self始终是我们子类的新创建的实例,而obj的类型对于三种实例创建方法是不同的。

  • 当从显式构造函数调用时,objNone

  • 当从视图转换调用时,obj可以是ndarray的任何子类的实例,包括我们自己的子类。

  • 当从模板创建新实例调用时,obj是我们自己子类的另一个实例,我们可以用它来更新新的self实例。

因为__array_finalize__是唯一始终看到新实例被创建的方法,所以它是为新对象属性填充实例默认值以及执行其他任务的合理位置。

一个例子可能会更清晰。

简单示例 - 向ndarray添加额外属性#

import numpy as np

class InfoArray(np.ndarray):

    def __new__(subtype, shape, dtype=float, buffer=None, offset=0,
                strides=None, order=None, info=None):
        # Create the ndarray instance of our type, given the usual
        # ndarray input arguments.  This will call the standard
        # ndarray constructor, but return an object of our type.
        # It also triggers a call to InfoArray.__array_finalize__
        obj = super().__new__(subtype, shape, dtype,
                              buffer, offset, strides, order)
        # set the new 'info' attribute to the value passed
        obj.info = info
        # Finally, we must return the newly created object:
        return obj

    def __array_finalize__(self, obj):
        # ``self`` is a new object resulting from
        # ndarray.__new__(InfoArray, ...), therefore it only has
        # attributes that the ndarray.__new__ constructor gave it -
        # i.e. those of a standard ndarray.
        #
        # We could have got to the ndarray.__new__ call in 3 ways:
        # From an explicit constructor - e.g. InfoArray():
        #    obj is None
        #    (we're in the middle of the InfoArray.__new__
        #    constructor, and self.info will be set when we return to
        #    InfoArray.__new__)
        if obj is None: return
        # From view casting - e.g arr.view(InfoArray):
        #    obj is arr
        #    (type(obj) can be InfoArray)
        # From new-from-template - e.g infoarr[:3]
        #    type(obj) is InfoArray
        #
        # Note that it is here, rather than in the __new__ method,
        # that we set the default value for 'info', because this
        # method sees all creation of default objects - with the
        # InfoArray.__new__ constructor, but also with
        # arr.view(InfoArray).
        self.info = getattr(obj, 'info', None)
        # We do not need to return anything

使用该对象如下所示:

>>> obj = InfoArray(shape=(3,)) # explicit constructor
>>> type(obj)
<class 'InfoArray'>
>>> obj.info is None
True
>>> obj = InfoArray(shape=(3,), info='information')
>>> obj.info
'information'
>>> v = obj[1:] # new-from-template - here - slicing
>>> type(v)
<class 'InfoArray'>
>>> v.info
'information'
>>> arr = np.arange(10)
>>> cast_arr = arr.view(InfoArray) # view casting
>>> type(cast_arr)
<class 'InfoArray'>
>>> cast_arr.info is None
True

这个类不是很有用,因为它与裸ndarray对象的构造函数相同,包括传递缓冲区和形状等等。我们可能更希望构造函数能够从通常的NumPy调用np.array中获取一个已经形成的ndarray并返回一个对象。

稍微更现实的例子 - 向现有数组添加属性#

这是一个类,它接收一个已经存在的标准ndarray,将其转换为我们的类型,并添加一个额外的属性。

import numpy as np

class RealisticInfoArray(np.ndarray):

    def __new__(cls, input_array, info=None):
        # Input array is an already formed ndarray instance
        # We first cast to be our class type
        obj = np.asarray(input_array).view(cls)
        # add the new attribute to the created instance
        obj.info = info
        # Finally, we must return the newly created object:
        return obj

    def __array_finalize__(self, obj):
        # see InfoArray.__array_finalize__ for comments
        if obj is None: return
        self.info = getattr(obj, 'info', None)

所以

>>> arr = np.arange(5)
>>> obj = RealisticInfoArray(arr, info='information')
>>> type(obj)
<class 'RealisticInfoArray'>
>>> obj.info
'information'
>>> v = obj[1:]
>>> type(v)
<class 'RealisticInfoArray'>
>>> v.info
'information'

__array_ufunc__ 用于ufunc#

子类可以通过覆盖默认的ndarray.__array_ufunc__方法来覆盖在对其执行NumPy ufunc时发生的情况。此方法将替代ufunc执行,并且应该返回操作的结果,或者如果请求的操作未实现则返回NotImplemented

__array_ufunc__的签名是:

def __array_ufunc__(ufunc, method, *inputs, **kwargs):
  • ufunc是调用的ufunc对象。

  • method是一个字符串,指示Ufunc是如何调用的,或者是"__call__"(表示直接调用),或者其方法之一:"reduce""accumulate""reduceat""outer""at"

  • inputsufunc的输入参数的元组。

  • kwargs包含传递给函数的任何可选参数或关键字参数。这包括任何out参数,这些参数始终包含在一个元组中。

一个典型的实现将转换任何作为自身类的实例的输入或输出,使用super()将所有内容传递给超类,最后在可能的反向转换后返回结果。一个例子,摘自_core/tests/test_umath.py中的测试用例test_ufunc_override_with_super,如下所示。

input numpy as np

class A(np.ndarray):
    def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
        args = []
        in_no = []
        for i, input_ in enumerate(inputs):
            if isinstance(input_, A):
                in_no.append(i)
                args.append(input_.view(np.ndarray))
            else:
                args.append(input_)

        outputs = out
        out_no = []
        if outputs:
            out_args = []
            for j, output in enumerate(outputs):
                if isinstance(output, A):
                    out_no.append(j)
                    out_args.append(output.view(np.ndarray))
                else:
                    out_args.append(output)
            kwargs['out'] = tuple(out_args)
        else:
            outputs = (None,) * ufunc.nout

        info = {}
        if in_no:
            info['inputs'] = in_no
        if out_no:
            info['outputs'] = out_no

        results = super().__array_ufunc__(ufunc, method, *args, **kwargs)
        if results is NotImplemented:
            return NotImplemented

        if method == 'at':
            if isinstance(inputs[0], A):
                inputs[0].info = info
            return

        if ufunc.nout == 1:
            results = (results,)

        results = tuple((np.asarray(result).view(A)
                         if output is None else output)
                        for result, output in zip(results, outputs))
        if results and isinstance(results[0], A):
            results[0].info = info

        return results[0] if len(results) == 1 else results

因此,这个类实际上并没有做任何有趣的事情:它只是将任何自身的实例转换为常规的ndarray(否则,我们将得到无限递归!),并添加一个info字典,说明它转换了哪些输入和输出。因此,例如:

>>> a = np.arange(5.).view(A)
>>> b = np.sin(a)
>>> b.info
{'inputs': [0]}
>>> b = np.sin(np.arange(5.), out=(a,))
>>> b.info
{'outputs': [0]}
>>> a = np.arange(5.).view(A)
>>> b = np.ones(1).view(A)
>>> c = a + b
>>> c.info
{'inputs': [0, 1]}
>>> a += b
>>> a.info
{'inputs': [0, 1], 'outputs': [0]}

请注意,另一种方法是使用getattr(ufunc, methods)(*inputs, **kwargs)代替super调用。对于此示例,结果将相同,但是如果另一个操作数也定义了__array_ufunc__,则存在差异。例如,假设我们评估np.add(a, b),其中b是另一个具有覆盖的类B的实例。如果像示例中那样使用superndarray.__array_ufunc__将注意到b具有覆盖,这意味着它无法自行评估结果。因此,它将返回NotImplemented,我们的类A也是如此。然后,控制权将传递给bb要么知道如何处理我们并产生结果,要么不知道并返回NotImplemented,从而引发TypeError

相反,如果我们将super调用替换为getattr(ufunc, method),我们将有效地执行np.add(a.view(np.ndarray), b)。同样,将调用B.__array_ufunc__,但现在它将ndarray作为另一个参数。它很可能知道如何处理它,并向我们返回B类的新实例。我们的示例类没有设置来处理这个问题,但是如果例如要使用__array_ufunc__重新实现MaskedArray,这可能是最佳方法。

最后一点:如果super路线适合给定的类,则使用它的一个优点是它有助于构建类层次结构。例如,假设我们的另一个类B也在其__array_ufunc__实现中使用了super,并且我们创建了一个依赖于两者的类C,即class C(A, B)(为了简单起见,没有另一个__array_ufunc__覆盖)。那么,对C实例的任何ufunc都将传递给A.__array_ufunc__A中的super调用将转到B.__array_ufunc__,而B中的super调用将转到ndarray.__array_ufunc__,从而允许AB协作。

__array_wrap__ 用于ufunc和其他函数#

在 NumPy 1.13 之前,ufunc 的行为只能使用 __array_wrap____array_prepare__ (后者现已移除) 进行调整。这两个方法允许更改 ufunc 的输出类型,但与 __array_ufunc__ 不同,它们不允许对输入进行任何更改。希望最终弃用这两个方法,但 __array_wrap__ 也被 NumPy 的其他函数和方法使用,例如 squeeze,因此目前仍然需要它来实现完整的功能。

从概念上讲,__array_wrap__ “包装操作”,允许子类设置返回值的类型并更新属性和元数据。让我们通过一个例子来演示它的工作原理。首先,我们回到更简单的子类示例,但使用不同的名称和一些打印语句。

import numpy as np

class MySubClass(np.ndarray):

    def __new__(cls, input_array, info=None):
        obj = np.asarray(input_array).view(cls)
        obj.info = info
        return obj

    def __array_finalize__(self, obj):
        print('In __array_finalize__:')
        print('   self is %s' % repr(self))
        print('   obj is %s' % repr(obj))
        if obj is None: return
        self.info = getattr(obj, 'info', None)

    def __array_wrap__(self, out_arr, context=None, return_scalar=False):
        print('In __array_wrap__:')
        print('   self is %s' % repr(self))
        print('   arr is %s' % repr(out_arr))
        # then just call the parent
        return super().__array_wrap__(self, out_arr, context, return_scalar)

我们对新数组实例运行 ufunc。

>>> obj = MySubClass(np.arange(5), info='spam')
In __array_finalize__:
   self is MySubClass([0, 1, 2, 3, 4])
   obj is array([0, 1, 2, 3, 4])
>>> arr2 = np.arange(5)+1
>>> ret = np.add(arr2, obj)
In __array_wrap__:
   self is MySubClass([0, 1, 2, 3, 4])
   arr is array([1, 3, 5, 7, 9])
In __array_finalize__:
   self is MySubClass([1, 3, 5, 7, 9])
   obj is MySubClass([0, 1, 2, 3, 4])
>>> ret
MySubClass([1, 3, 5, 7, 9])
>>> ret.info
'spam'

请注意,ufunc (np.add) 已使用参数 self 作为 obj,以及 out_arr 作为加法的 (ndarray) 结果调用了 __array_wrap__ 方法。反过来,默认的 __array_wrap__ (ndarray.__array_wrap__) 已将结果转换为 MySubClass 类,并调用了 __array_finalize__ - 因此复制了 info 属性。所有这些都在 C 层面完成。

但是,我们可以做任何我们想做的事情。

class SillySubClass(np.ndarray):

    def __array_wrap__(self, arr, context=None, return_scalar=False):
        return 'I lost your data'
>>> arr1 = np.arange(5)
>>> obj = arr1.view(SillySubClass)
>>> arr2 = np.arange(5)
>>> ret = np.multiply(obj, arr2)
>>> ret
'I lost your data'

因此,通过为我们的子类定义特定的 __array_wrap__ 方法,我们可以调整 ufunc 的输出。__array_wrap__ 方法需要 self,然后是一个参数 - 这是 ufunc 或其他 NumPy 函数的结果 - 以及一个可选参数 *context*。ufunc 将此参数作为 3 元素元组传递:(ufunc 的名称,ufunc 的参数,ufunc 的域),但其他 NumPy 函数不会传递此参数。尽管如上所示,可以这样做,但 __array_wrap__ 应该返回其包含类的实例。请参阅掩码数组子类以了解实现。__array_wrap__ 始终传递一个 NumPy 数组,该数组可能是也可能不是子类(通常是调用者的子类)。

额外注意事项 - 自定义 __del__ 方法和 ndarray.base#

ndarray 解决的问题之一是跟踪 ndarray 及其视图的内存所有权。考虑我们创建了一个 ndarray,arr,并使用 v = arr[1:] 获取了一个切片。这两个对象都在查看相同的内存。NumPy 使用 base 属性跟踪特定数组或视图的数据来源。

>>> # A normal ndarray, that owns its own data
>>> arr = np.zeros((4,))
>>> # In this case, base is None
>>> arr.base is None
True
>>> # We take a view
>>> v1 = arr[1:]
>>> # base now points to the array that it derived from
>>> v1.base is arr
True
>>> # Take a view of a view
>>> v2 = v1[1:]
>>> # base points to the original array that it was derived from
>>> v2.base is arr
True

一般来说,如果数组拥有自己的内存,例如本例中的 arr,则 arr.base 将为 None - 但也有一些例外 - 请参阅 NumPy 手册了解更多详情。

base 属性有助于判断我们拥有的是视图还是原始数组。如果我们需要知道在删除子类数组时是否需要进行一些特定的清理,这将非常有用。例如,我们可能只想在删除原始数组时进行清理,而不是视图。有关这如何工作的示例,请查看 numpy._core 中的 memmap 类。

子类化和下游兼容性#

在对 ndarray 进行子类化或创建模仿 ndarray 接口的鸭子类型时,您有责任决定您的 API 与 NumPy 的 API 的一致程度。为方便起见,许多具有相应 ndarray 方法的 NumPy 函数(例如 summeantakereshape)的工作原理是检查函数的第一个参数是否具有相同名称的方法。如果存在,则调用该方法,而不是将参数强制转换为 NumPy 数组。

例如,如果您希望您的子类或鸭子类型与 NumPy 的 sum 函数兼容,则此对象的 sum 方法的方法签名应如下所示:

def sum(self, axis=None, dtype=None, out=None, keepdims=False):
...

这与 np.sum 的方法签名完全相同,因此现在如果用户在此对象上调用 np.sum,NumPy 将调用该对象的 sum 方法并传入上面签名中列出的这些参数,并且不会引发任何错误,因为签名彼此完全兼容。

但是,如果您决定偏离此签名并执行以下操作:

def sum(self, axis=None, dtype=None):
...

此对象不再与 np.sum 兼容,因为如果您调用 np.sum,它将传入意外的参数 outkeepdims,从而导致引发 TypeError。

如果您希望保持与 NumPy 及其后续版本(可能添加新的关键字参数)的兼容性,但又不想显示所有 NumPy 的参数,则您的函数签名应该接受 **kwargs。例如:

def sum(self, axis=None, dtype=None, **unused_kwargs):
...

此对象现在再次与 np.sum 兼容,因为任何多余的参数(即不是 axisdtype 的关键字)都将隐藏在 **unused_kwargs 参数中。