NumPy ndarray 子类化完全指南:从视图转换、`__new__` 机制到 `__array_ufunc__` 与下游兼容性
NumPy ndarray 子类化完全指南从视图转换、__new__机制到__array_ufunc__与下游兼容性【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy导读本指南基于 NumPy 官方文档 doc/source/user/basics.subclassing.rst 编写系统讲解如何继承np.ndarray创建自定义数组子类。与普通 Python 类不同ndarray 子类的实例可能通过显式构造、视图转换view casting、从模板新建new-from-template三条路径产生理解这套机制是写出健壮子类的关键。读完本文你将掌握__new__与__array_finalize__的分工、如何为子类附加自定义属性、如何用__array_ufunc__拦截 ufunc 运算、__array_wrap__的包装语义以及如何设计与np.sum等函数兼容的方法签名。ndarray 子类实例的三种创建途径子类化 ndarray 相对简单但与普通 Python 对象相比存在一些特殊之处。根本原因在于ndarray 子类的新实例可以通过三种不同的方式产生任何子类实现都必须同时处理这三条路径显式构造函数调用——例如MySubClass(params)这是 Python 实例创建的常规路径视图转换view casting——把已有的 ndarray 强制转换为某个指定子类从模板新建new-from-template——根据一个模板实例创建新实例例如对子类数组切片、ufunc 创建返回数组、复制数组c_arr.copy()等详见 new-from-template 一节。后两条路径是 ndarray 特有的——为了支持数组切片等操作NumPy 必须能在不经过用户__new__的情况下再造出同类实例。子类化 ndarray 的复杂性正源于 NumPy 为支撑这两条实例创建路径而引入的机制。何时应该使用子类化除了额外的复杂性子类还可能遇到意外行为某些 NumPy 函数可能把子类转换为基类 ndarray从而遗忘子类上附带的额外信息。如果使用了未经充分测试的 NumPy 方法或函数就可能产生出乎意料的结果。另一方面与其他互操作方案相比子类化的好处是很多东西会自动工作因此长期以来它既方便也往往是唯一可行的方案。不过NumPy 现在提供了更多的互操作协议详见文档 Interoperability with NumPy。对许多用例而言这些互操作协议可能更适合或可以作为子类化的补充。子类化比较适合以下场景你不太担心可维护性或者用户只有你自己子类实现更快互操作能力可以按需补充用户少意外行为就不是大问题你认为子类信息被静默忽略或丢失是可以接受的。例如np.memmap即便遗忘了数据是内存映射这一事实也不会导致错误结果而 NumPy 的 masked arrays 是另一个例子——它在引入时子类化是唯一实现手段但今天可能会尽量避免子类化而只依赖互操作协议。此外子类作者也应研读 Interoperability with NumPy以支持更复杂的用例或规避意外行为。astropy.units.Quantity与xarray就是与 NumPy 互操作良好的数组对象范例其中 Astropy 的Quantity采用了子类化 互操作协议双管齐下的策略。视图转换View casting视图转换是 ndarray 的标准机制取任意子类的 ndarray返回其作为另一个指定子类的视图import numpy as np # 创建一个完全无用的 ndarray 子类 class C(np.ndarray): pass # 创建一个标准 ndarray arr np.zeros((3,)) # 取一个视图作为我们的无用子类 c_arr arr.view(C) type(c_arr) # class __main__.C从模板新建New-from-template子类的新实例还可以通过与视图转换非常相似的机制产生当 NumPy 需要根据一个模板实例创建新实例时。最典型的场景是对子类数组切片v c_arr[1:] type(v) # 视图的类型是 C # class __main__.C v is c_arr # 但它是新实例 # False切片是原c_arr数据上的视图。也就是说当从 ndarray 取视图时会返回一个同类的、指向原数据的新 ndarray。除此之外还有多处需要这类视图复制数组c_arr.copy()、创建 ufunc 输出数组见下文array_wrap、以及归约方法如c_arr.mean()。视图转换与从模板新建的关系这两条路径使用同一套底层机制本文之所以区分它们是因为它们会向你的方法传入不同的参数。具体而言视图转换从 ndarray 的任意潜在子类创建你数组类型的新实例从模板新建从既有实例创建你类的新实例——这允许你例如把子类特有的属性复制到新实例上。底层机制__new__、__init__与__array_finalize__子类化 ndarray 时不仅要处理显式构造还要处理视图转换和从模板新建。NumPy 为此提供的机制正是子类化不那么标准的原因。这套机制有两个方面使用ndarray.__new__方法承担对象初始化的主要工作而不是更常见的__init__方法使用__array_finalize__方法让子类在视图和从模板新建之后完成善后。Python 中__new__与__init__的速成课__new__是标准 Python 方法如果存在它会在__init__之前被调用参考 Python 官方文档对object.__new__的说明。例如class C: def __new__(cls, *args): print(Cls in __new__:, cls) print(Args in __new__:, args) # object 类型的 __new__ 方法只接受一个参数 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我们希望__new__能返回另一个类的对象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): # 我们永远不会走到这里 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 CC的定义与之前相同但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 提供的、让子类处理各种新实例创建方式的机制。回顾子类实例的三种来源显式构造函数调用obj MySubClass(params)——会走常规的MySubClass.__new__然后是如果存在MySubClass.__init__视图转换从模板新建。我们的MySubClass.__new__只在显式构造时被调用因此不能依赖__new__或__init__处理视图转换和从模板新建。而MySubClass.__array_finalize__对三种创建方式都会被调用所以对象创建的家务活通常放在这里对显式构造函数调用子类需要创建自身类的新 ndarray 实例。实践中需要我们代码作者调用ndarray.__new__(MySubClass, ...)、通过super().__new__(cls, ...)做类层级准备调用或对既有数组做视图转换见下对视图转换和从模板新建相当于在 C 层调用ndarray.__new__(MySubClass, ...)。__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): # 实践中你的子类可能不需要 __init__ 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))观察三种路径# 显式构造函数 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 # 视图转换 a np.arange(10) cast_a a.view(C) # In array_finalize: # self type is class C # obj type is type numpy.ndarray # 切片从模板新建的示例 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__它把新对象我们的类即self以及视图来源对象obj传给__array_finalize__。从输出可见self总是我们子类的新实例而obj的类型随三种创建方式不同显式构造调用时obj是None视图转换时obj可以是 ndarray 的任何子类实例包括我们自己的类从模板新建时obj是我们子类的另一个实例可用于更新新的self实例。由于__array_finalize__是唯一能看见所有新实例创建的方法它理所当然地成为为新对象属性填充默认值的地方。C 层实现依据在 C 实现 numpy/_core/src/multiarray/ctors.c 中可以看到这套机制的落地细节在调用__array_finalize__之前C 层会先通过PyArray_SetBaseObject设置好新对象的base注释明确写道 Set the base object. Its important to do it here so thatarray_finalizebelow receives it——这正是__array_finalize__里能用obj.base追踪数据来源的前提只有请求了子类型subtype ! PyArray_Type才会触发调用当obj为 NULL 时C 层会用Py_None作为回调参数传入对应 Python 侧显式构造时obj is None的现象出于性能考虑如果__array_finalize__是从 ndarray 继承来的空实现C 层会直接跳过调用For speed, we skip ifarray_finalizeis inherited from ndarray。这解释了为什么子类必须自己实现__array_finalize__才能收到回调也说明 NumPy 在热路径上对该调用做了专门优化。简单示例给 ndarray 附加一个属性import numpy as np class InfoArray(np.ndarray): def __new__(subtype, shape, dtypenp.float64, bufferNone, offset0, stridesNone, orderNone, infoNone): # 用常规 ndarray 输入参数创建我们自己类型的 ndarray 实例。 # 这会调用标准的 ndarray 构造器但返回我们类型的对象。 # 同时也会触发一次 InfoArray.__array_finalize__ 调用。 obj super().__new__(subtype, shape, dtype, buffer, offset, strides, order) # 把新属性 info 设为传入的值 obj.info info # 最后必须返回新创建的对象 return obj def __array_finalize__(self, obj): # self 是 ndarray.__new__(InfoArray, ...) 产生的新对象 # 因此它只有 ndarray.__new__ 构造器赋予的属性——即标准 ndarray 的那些属性。 # # 到达 ndarray.__new__ 调用可能有 3 种方式 # 显式构造函数如 InfoArray(): # obj is None # 我们正处于 InfoArray.__new__ 构造过程中 # self.info 会在返回 InfoArray.__new__ 后被设置 if obj is None: return # 视图转换如 arr.view(InfoArray): # obj is arr # type(obj) 可以是 InfoArray # 从模板新建如 infoarr[:3] # type(obj) 是 InfoArray # # 注意给 info 设置默认值要放在这里而不是 __new__ 方法中 # 因为这个方法能看到所有默认对象的创建——包括 # InfoArray.__new__ 构造器也包括 arr.view(InfoArray)。 self.info getattr(obj, info, None) # 无需返回任何值使用效果obj InfoArray(shape(3,)) # 显式构造函数 type(obj) # class InfoArray obj.info is None # True obj InfoArray(shape(3,), infoinformation) obj.info # information v obj[1:] # 从模板新建——这里是切片 type(v) # class InfoArray v.info # information arr np.arange(10) cast_arr arr.view(InfoArray) # 视图转换 type(cast_arr) # class InfoArray cast_arr.info is None # True这个类实用性一般因为它的构造器与裸 ndarray 相同要传 buffer、shape 等参数。我们更希望构造器能接受np.array等常规 NumPy 调用产生的已成型 ndarray 并返回对象。更实际的示例为既有数组附加属性下面这个类接受一个已存在的标准 ndarray转换为我们的类型并附加一个额外属性import numpy as np class RealisticInfoArray(np.ndarray): def __new__(cls, input_array, infoNone): # input_array 是一个已成型 ndarray 实例 # 首先转换为我们的类类型 obj np.asarray(input_array).view(cls) # 给创建出的实例添加新属性 obj.info info # 最后必须返回新创建的对象 return obj def __array_finalize__(self, obj): # 参见 InfoArray.__array_finalize__ 的注释 if obj is None: return self.info getattr(obj, info, None)效果arr np.arange(5) obj RealisticInfoArray(arr, infoinformation) type(obj) # class RealisticInfoArray obj.info # information v obj[1:] type(v) # class RealisticInfoArray v.info # information注意这里__new__里的np.asarray(input_array).view(cls)先创建标准 ndarray 再转换视图__array_finalize__则确保后续切片、复制、ufunc 输出等所有从模板新建的实例都能继承info属性。__array_ufunc__拦截 ufunc 运算子类可以通过重写默认的ndarray.__array_ufunc__方法控制对自身执行 NumPy ufunc 时的行为。该方法代替ufunc 本身执行应返回运算结果如果请求的操作未实现则返回NotImplemented。签名如下def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):ufunc被调用的 ufunc 对象method字符串指示 ufunc 的调用方式为__call__直接调用或其一众方法之一reduce、accumulate、reduceat、outer、at参见 basics.ufuncs 中关于 ufunc 方法的说明inputs传给 ufunc 的输入参数元组kwargs传给函数的所有可选参数和关键字参数包括out参数——它总是被包含在元组中。典型实现是把自身类的输入/输出实例转换为普通 ndarray用super()把一切转交给超类最后对结果做可能的反向转换。下面的示例取自测试用例test_ufunc_override_with_super定义于 numpy/_core/tests/test_umath.py该测试的注释明确说明此类被本文档使用修改时需同步更新文档import numpy as np class A(np.ndarray): def __array_ufunc__(self, ufunc, method, *inputs, outNone, **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]}在 test_umath.py 中该示例还与另一个定义了__array_ufunc__的类B联合作了验证np.sin(d, out(a,))会返回a本身b.info {outputs: [0]}np.sin(a, outa)则同时记录输入与输出。super()调用 vsgetattr(ufunc, method)另一种做法是用getattr(ufunc, methods)(*inputs, **kwargs)取代super调用。对本例而言结果相同但当另一个操作数也定义了__array_ufunc__时会有差别。假设计算np.add(a, b)其中b是另一个有重写方法的类B的实例若使用super如示例所示ndarray.__array_ufunc__会注意到b有重写它无法自行求值于是返回NotImplemented我们的类A也随之返回NotImplemented控制权转交给b——b要么知道如何处理并产生结果要么也返回NotImplemented最终抛出TypeError若改用getattr(ufunc, method)相当于执行np.add(a.view(np.ndarray), b)。B.__array_ufunc__仍会被调用但此时它看到另一个参数是普通 ndarray多半能处理并返回一个B类的新实例。示例类并未准备好处理这种情况但若要用__array_ufunc__重实现MaskedArray这可能是更好的方案。最后一点如果super路线适合某类它的一个优势是有助于构建类层级。例如假设类B也使用super而我们创建同时依赖两者的类C即class C(A, B)为简单起见不再重写__array_ufunc__。那么对C实例执行任何 ufunc 都会传给A.__array_ufunc__A中的super调用进入B.__array_ufunc__B中的super调用再进入ndarray.__array_ufunc__从而让A和B协同工作。__array_wrap__用于 ufunc 与其他函数在 NumPy 1.13 之前ufunc 的行为只能通过__array_wrap__和__array_prepare__后者现已移除调节。二者可以改变 ufunc 的输出类型但与__array_ufunc__不同不能对输入做任何修改。NumPy 希望最终弃用这些机制但__array_wrap__仍被squeeze等其他 NumPy 函数和方法使用所以目前仍为完整功能所必需。概念上__array_wrap__包装动作——允许子类设置返回值的类型并更新属性与元数据。回到较简单的示例子类换一个名字并加些打印语句import numpy as np class MySubClass(np.ndarray): def __new__(cls, input_array, infoNone): 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, contextNone, return_scalarFalse): print(In __array_wrap__:) print( self is %s % repr(self)) print( arr is %s % repr(out_arr)) # 然后直接调用父类 return super().__array_wrap__(self, out_arr, context, return_scalar)对实例执行一个 ufuncobj MySubClass(np.arange(5), infospam) # 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注意 ufuncnp.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, contextNone, return_scalarFalse): 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。context由 ufunc 以三元组传入(ufunc 名称, ufunc 参数, ufunc 的 domain)其他 NumPy 函数不传。虽然可以像上面那样返回任意值但__array_wrap__应当返回其所在类的实例可参考 masked array 子类的实现。__array_wrap__总是收到一个 NumPy 数组它可能是也可能不是子类通常是调用者的子类。参考实现np.memmap仓库中的 numpy/_core/memmap.py 是这套机制在生产代码中的范例__array_finalize__检查obj是否共享内存np.may_share_memory(self, obj)若是则继承_mmap、filename、offset、mode否则全部置空——保证切片、视图不会错误地共享 mmap 生命周期之外的状态__array_wrap__调用super().__array_wrap__后仅在self is arr且类型恰为memmap时做特殊处理return_scalar时返回标量例如np.sum带axisNone时不返回 0 维 memmap否则返回普通 ndarray 视图避免运算结果意外地变成 mmap 输出。额外注意事项自定义__del__方法与ndarray.basendarray 解决的核心问题之一是跟踪 ndarray 及其视图的内存所有权。考虑创建arr后取切片v arr[1:]两个对象共享同一块内存。NumPy 用base属性跟踪数组或视图的数据来源# 一个拥有自己数据的普通 ndarray arr np.zeros((4,)) # 这种情况下 base 是 None arr.base is None # True # 取一个视图 v1 arr[1:] # base 现在指向它派生自的数组 v1.base is arr # True # 再取视图的视图 v2 v1[1:] # base 指向它最初派生自的原数组 v2.base is arr # True一般来说如果数组拥有自己的内存如这里的arrarr.base为None有一些例外可参考 NumPy 相关专著。base属性可用于判断当前对象是视图还是原数组进而判断子类数组被删除时是否需要做特定清理——例如只在原数组被删除时清理而不是视图被删除时。这方面的完整示例可参考numpy._core中的memmap类见上文 memmap.py 中__new__保存self._mmap、__array_finalize__按共享关系复制的写法以及flush中通过self.base.flush()逐级刷盘的思路。子类化与下游兼容性子类化ndarray或创建模仿 ndarray 接口的鸭子类型时你有责任决定自己的 API 与 NumPy API 的对齐程度。为方便起见许多有对应ndarray方法的 NumPy 函数如sum、mean、take、reshape会检查第一个参数是否具有同名方法如果存在就调用该方法而不是把参数强制转换为 NumPy 数组。例如若希望子类或鸭子类型与 NumPy 的sum函数兼容该对象sum方法的签名应如下def sum(self, axisNone, dtypeNone, outNone, keepdimsFalse): ...这与np.sum的签名完全相同。用户对这类对象调用np.sum时NumPy 会调用对象自身的sum方法并传入签名中的这些参数由于签名完全兼容不会报错。但如果你偏离了签名例如def sum(self, axisNone, dtypeNone): ...该对象就不再与np.sum兼容调用np.sum时会传入意外的out和keepdims参数引发TypeError。如果你希望与 NumPy 及其后续版本可能会新增关键字参数保持兼容又不想暴露 NumPy 的全部参数则函数签名应接受**kwargsdef sum(self, axisNone, dtypeNone, **unused_kwargs): ...该对象重新变得与np.sum兼容任何多余的参数即axis、dtype之外的关键字都会被收纳进**unused_kwargs。小结子类化 ndarray 的核心心智模型可以概括为三句话三条创建路径都要覆盖显式构造、视图转换、从模板新建__array_finalize__是唯一三者皆达的钩子属性默认值应放在这里初始化全部交给__new__ndarray 没有__init__通过ndarray.__new__(subtype, ...)才能构造出正确类型的实例C 层在 ctors.c 中负责设置base并触发__array_finalize__ufunc 与函数级兼容按协议走__array_ufunc__拦截并转换输入输出参考 test_umath.py 的权威示例__array_wrap__调整返回值类型与元数据参考 memmap.py而为sum这类函数提供兼容签名时务必保留**kwargs以应对 NumPy 后续新增参数。需要进一步了解数组互操作协议的读者可继续阅读仓库中的 basics.interoperability.rst以及 basics.ufuncs.rst 中关于 ufunc 方法的完整说明。【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →