当前位置: 代码迷 >> 综合 >> Python 中 NumPy 的子类化 ndarray
  详细解决方案

Python 中 NumPy 的子类化 ndarray

热度:16   发布时间:2024-01-09 19:19:16.0

介绍

子类化 ndarray 相对简单,但与其他 Python 对象相比,有一定的复杂性。这里,我是介绍如何子类化 ndarray。

ndarrays 和对象创建

ndarray 的子??类化很复杂,因为 ndarray 类的新实例可以以三种不同的方式出现。这些是:

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

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

3. 模板中的新内容 - 从模板实例创建新实例。包括从子类化数组返回切片,从 ufuncs 创建返回类型以及复制数组。

最后两个是 ndarrays 的特性 - 为了支持数组切片之类的东西。子类化 ndarray 的复杂性是由于 numpy 必须支持后两种实例创建路径的机制。

视图转换

视图转换是标准的 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 'C'>

从模板创建

当 numpy 发现它需要从模板实例创建新实例时,ndarray 子类的新实例也可以通过与试图转换非常相似的机制来实现。 这个情况的最明显的时候是正为子类数组切片的时候。例如:

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

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

在使用 ndarrays 时还有其它要点,有时需要这样的视图,例如复制数组( c_arr.copy() ),创建 ufunc 输出数组, 以及 reduce 方法,如 c_arr.mean()。

视图转换与从模板创建的关系

这些方法都使用相同的机制。因为得到的结果不同,因此会有区别。具体来说, 视图转换意味着已从 ndarray 的任一可能子类创建了数组类型的新实例。 从模板创建意味着已从预先存在的实例创建了类的新实例。

子类化的含义

如果要将 ndarray 子类化,不仅需要处理数组类型的显式构造,还需要处理视图转换或从模板转换。NumPy 有这样的机制,但这种机制使子类化略微不标准。

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

第一种是使用 ndarray.__new__ 方法进行对象初始化的主要工作,而不是更常用的 __init__ 方法。

第二个是使用 __array_finalize__ 方法在模板创建视图和新实例后允许子类清理。

一个简短的 Python 入门:__new__ 和 __init__

__new__ 是一个标准的 Python 方法,如果存在,__init__ 在我们创建类实例之前调用它。

例如:

class C(object):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 'C'>
Args in __new__: ('hello',)
type(self) in __init__: <class '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 hereprint('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。该 __init__ 方法 D 不会被调用。通常,当 __new__ 方法返回类的对象而不是定义 __init__  它的类时,不调用该类的方法。

这就是 ndarray 类的子类如何能够返回保留类类型的视图。在进行视图时,标准的 ndarray 机器会创建新的 ndarray 对象,例如:

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

subdtype 为子类。因此,返回的视图与子类属于同一类,而不是类 ndarray。

这解决了返回相同类型的视图的问题,但是现在我们有了一个新的问题。 ndarray 的机制可以在其用于获取视图的标准方法中这样设置类, 但是 ndarray 的 __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__ 在创建对象的三种方法中都被调用。

1. 对于显式构造函数调用,子类需要创建自己的类的新 ndarray 实例。 在实践中,这意味着作为代码的作者将需要调用 ndarray.__new__(MySubClass,...), 一个类层次结构调用 super(MySubClass, cls).__new__(cls, ...) , 或者进行现有数组的视图转换

2. 对于视图转换和从模板创建,在 C 级别调用ndarray.__new__(MySubClass,...的等效项。

对于上述三种实例创建方法,__array_finalize__ 接收的参数不同。

import numpy as npclass C(np.ndarray):def __new__(cls, *args, **kwargs):print('In __new__ with class %s' % cls)return super(C, cls).__new__(cls, *args, **kwargs)def __init__(self, *args, **kwargs):# in practice you probably will not need or want an __init__# method for your subclassprint('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 从模板创建)
>>> 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__  传递了自己的 class(self)的新对象以及从中获取视图的对象(obj)。从上面的输出可以看出,self 它总是一个新创建的子类实例,并且 obj 三种实例创建方法的类型不同:

1. 从显式构造函数调用时,obj 是 None

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

3. 在从模板创建中调用时,obj 是我们自己的子类的另一个实例,我们可能会用它来更新新的 self 实例。

因为 __array_finalize__ 是唯一始终看到正在创建新实例的方法,所以在其他任务中填充新对象属性的实例默认值是合理的。

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

import numpy as npclass 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(InfoArray, subtype).__new__(subtype, shape, dtype,buffer, offset, strides,order)# set the new 'info' attribute to the value passedobj.info = info# Finally, we must return the newly created object:return objdef __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 从模板创建 - 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:] # 从模板创建 - 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 对象具有相同的构造函数,包括传入缓冲区和形状等等。我们可能希望构造函数能够从对np.array 的常规 numpy 调用中获取已经形成的 ndarray 并返回一个对象。

稍微更现实的例子: 添加到现有数组的属性

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

import numpy as npclass 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 typeobj = np.asarray(input_array).view(cls)# add the new attribute to the created instanceobj.info = info# Finally, we must return the newly created object:return objdef __array_finalize__(self, obj):# see InfoArray.__array_finalize__ for commentsif obj is None: returnself.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__ 对于 ufuncs

子类可以通过覆盖默认的 ndarray 来覆盖在其上执行 numpy ufuncs 时发生的情况。执行此方法而不是 ufunc,并且应该返回操作的结果, 如果未执行所请求的操作返回 NotImplemented。

__array_ufunc__ 说明为:

def __array_ufunc__(ufunc, method, *inputs, **kwargs):- *ufunc* is the ufunc object that was called.
- *method* is a string indicating how the Ufunc was called, either``"__call__"`` to indicate it was called directly, or one of its:ref:`methods<ufuncs.methods>`: ``"reduce"``, ``"accumulate"``,``"reduceat"``, ``"outer"``, or ``"at"``.
- *inputs* is a tuple of the input arguments to the ``ufunc``
- *kwargs* contains any optional or keyword arguments passed to thefunction. This includes any ``out`` arguments, which are alwayscontained in a tuple.

典型的实现将转换自定义类实例的任何输入或输出,使用 super() 传递所有内容给超类,并最终在可能的反向转换后返回结果。

举例如下。

import numpy as npclass A(np.ndarray):def __array_ufunc__(self, ufunc, method, *inputs, **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 = kwargs.pop('out', None)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.noutinfo = {}if in_no:info['inputs'] = in_noif out_no:info['outputs'] = out_noresults = super(A, self).__array_ufunc__(ufunc, method,*args, **kwargs)if results is NotImplemented:return NotImplementedif method == 'at':if isinstance(inputs[0], A):inputs[0].info = inforeturnif 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 = inforeturn 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,method)(*input,*kwargs) 而不是 super call。 对于本例,结果是相同的,但如果另一个操作数也定义了 __array_ufunc__ ,则会有所不同。 例如,假设我们评估 np.add(a,b),其中 b 是具有覆盖的另一个类 B 的实例。 如果在示例中使用 super,ndarray.__array_ufunc__ 会注意到 b 具有覆盖,这意味着它不能计算结果本身。 因此,它将返回 NotImplemented ,我们的类A 也将如此。 然后,控制权将传递给 b,b 要么知道如何处理并产生结果,要么不知道并返回 NotImplemented,从而引发 TypeError。

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

最后要注意:如果 super 路由适合给定的类,使用它的一个优点是它有助于构造类层次结构。 例如,假设我们的其他类B在其 __array_ufunc__ 实现中也使用了 super, 并且我们创建了一个依赖于它们的类 C,即 calss C(A, B)(为简单起见,没有另一个 __array_ufunc__ 覆盖)。 然后,C实例上的任何ufunc都将传递给 A.__ array_ufunc__, A 中的超级调用将转到 B.__ array_ufunc__, 而 B 中的 super call 将转到 ndarray.__array_ufunc__ ,从而允许 A和 B 协作。

__array_wrap__ 用于 ufuncs 和其他函数

从概念上讲,__array_wrap__ “包装动作” 的意义是允许子类设置返回值的类型并更新属性和元数据。 让我们用一个例子来说明它是如何工作的。首先,我们返回到简单的 Example 子类,但具有不同的名称和一些 print 语句:

import numpy as npclass MySubClass(np.ndarray):def __new__(cls, input_array, info=None):obj = np.asarray(input_array).view(cls)obj.info = inforeturn objdef __array_finalize__(self, obj):print('In __array_finalize__:')print('   self is %s' % repr(self))print('   obj is %s' % repr(obj))if obj is None: returnself.info = getattr(obj, 'info', None)def __array_wrap__(self, out_arr, context=None):print('In __array_wrap__:')print('   self is %s' % repr(self))print('   arr is %s' % repr(out_arr))# then just call the parentreturn super(MySubClass, self).__array_wrap__(self, out_arr, context)

运行 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_finalize__:self is MySubClass([1, 2, 3, 4, 5])obj is MySubClass([0, 1, 2, 3, 4])
In __array_wrap__:self is MySubClass([0, 1, 2, 3, 4])arr is MySubClass([1, 3, 5, 7, 9])
>>> ret
MySubClass([1, 3, 5, 7, 9])
>>> ret.info
'spam'

注意,ufunc(np.add)  调用了 __array_wrap__ 方法,参数 self 作为 obj,out_arr 作为加法的 (ndarray) 结果。 反过来,默认 __array_wrap__(ndarray._array_warp__) 已将结果强制转换为类 MySubClass,并调用 __array_finalize__ 因此复制了 info 属性。

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

class SillySubClass(np.ndarray):def __array_wrap__(self, arr, context=None):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__ 为我们的子类定义一个特定的方法,我们可以调整 ufuncs 的输出。 该 __array_wrap__ 方法需要 self,然后是一个参数(这是 ufunc 的结果) 和一个可选的参数 context。 ufuncs 将此参数作为 3 元素元组返回:( ufunc 的名称,ufunc 的参数,ufunc 的域), 但不是由其他 numpy 函数设置的。

除了 __array_wrap__ 在 ufunc 之外调用之外, 还有一个 __array_prepare__ 方法在创建输出数组之后但在执行任何计算之前调用ufunc。 默认实现除了通过数组之外什么都不做。__array_prepare__ 不应尝试访问数组数据或调整数组大小, 它用于设置输出数组类型,更新属性和元数据,以及根据计算开始之前可能需要的输入执行任何检查。 比如__array_wrap__,__array_prepare__必须返回一个ndarray 或其子类或引发错误。

额外的坑: 自定义的 __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 view it derived from
>>> v2.base is v1
True

一般来说,如果数组拥有自己的内存, 就像 arr 在这种情况下那样, 那么 arr.base 将是None。

该 base 属性可用于判断是否有视图或原始数组。 

子类和下游兼容性

当子类化 ndarray 或创建模仿 ndarray 接口的 duck-types 时, 要确定自定义 API 与 numpy 的 API 将如何对齐。 为方便起见,许多具有相应 ndarray 方法(例如,sum,mean,take,reshape)的 Numpy 函数通过检查函数的第一个参数是否具有同名的方法来工作。 如果存在,则调用该方法,而不是将参数强制到 numpy 数组。

例如,如果您希望子类或 duck-type 与 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,它将传递意外的参数 out 和 keepdims 导致引发 TypeError。

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

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

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

参考资料:

1. NumPy 官方文档:https://numpy.org/devdocs/