Подклассы ndarray
Введение
Подклассы ndarray относительно просты, но по сравнению с другими объектами Python они имеют некоторые сложности. На этой странице мы объясним механизмы, которые позволяют создавать подклассы ndarray, и последствия их реализации.
ndarray и создание объектов
Подклассы ndarray усложняются тем, что новые экземпляры классов ndarray могут появляться тремя различными способами:
- Явное вызов конструктора, как в
MySubClass(params). Это обычный путь к созданию экземпляров Python. - Преобразование представления — приведение существующего ndarray к заданному подклассу.
- Создание нового из шаблона — создание нового экземпляра из экземпляра-шаблона. Примеры включают возвращение срезов из подклассированного массива, создание типов возврата из функций ufunc и копирование массивов. Дополнительные сведения см. в разделе Создание нового из шаблона.
Два последних способа характерны для ndarray — они необходимы для поддержки таких операций, как взятие срезов массива. Сложности при наследовании от 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'>
Создание нового из шаблона
Новые экземпляры подкласса ndarray также могут появляться с помощью механизма, очень похожего на механизм преобразования представления, когда NumPy обнаруживает необходимость создания нового экземпляра из экземпляра-шаблона. Наиболее очевидное место, где это необходимо, — при взятии срезов подклассированных массивов. Например:
>>> 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 того же класса, который указывает на данные в оригинале.
Есть и другие случаи использования ndarray, где нам нужны такие представления, такие как копирование массивов (c_arr.copy()), создание массивов вывода функций ufunc (см. также __array_wrap__ для функций ufunc и других функций) и методы уменьшения (например, c_arr.mean()).
Взаимосвязь преобразования представления и создания нового из шаблона
Оба эти пути используют одни и те же механизмы. Различие здесь заключается в том, что они приводят к разным входным данным для ваших методов. В частности, преобразование представления означает, что вы создали новый экземпляр вашего типа массива из любого потенциального подкласса ndarray. Создание нового из шаблона означает, что вы создали новый экземпляр вашего класса из существующего экземпляра, что позволяет, например, скопировать атрибуты, специфичные для вашего подкласса.
Последствия наследования
Если мы создаём подкласс ndarray, нам необходимо обрабатывать не только явное создание нашего типа массива, но также преобразование представления или создание нового из шаблона. NumPy имеет механизмы для этого, и эти механизмы делают наследование немного нестандартным.
В механизме ndarray для поддержки представлений и создания нового из шаблона в подклассах есть два аспекта.
Первый — использование метода ndarray.__new__ для основной работы по инициализации объектов вместо более обычного метода __init__. Второй — использование метода __array_finalize__ для обработки подклассов после создания представлений и новых экземпляров из шаблонов.
Краткий справочник по Python для __new__ и __init__
__new__ — это стандартный метод Python, и, если он присутствует, он вызывается перед __init__ при создании экземпляра класса. См. документацию python по __new__ для получения более подробной информации.
Например, рассмотрим следующий код Python:
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 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. Обратите внимание, что метод __init__ класса D не вызывается. Вообще, когда метод __new__ возвращает объект класса, отличного от класса, в котором он определён, метод __init__ этого класса не вызывается.
Вот как подклассы класса ndarray могут возвращать представления, сохраняющие тип класса. При взятии представления стандартный механизм ndarray создаёт новый объект ndarray с чем-то вроде:
obj = ndarray.__new__(subtype, shape, ...
где subdtype — подкласс. Таким образом, возвращаемое представление имеет тот же класс, что и подкласс, а не класс ndarray.
Это решает проблему возвращения представлений того же типа, но теперь у нас есть новая проблема. Механизм ndarray может установить класс таким образом в своих стандартных методах для взятия представлений, но метод ndarray __new__ ничего не знает о том, что мы сделали в своём методе __new__ для установки атрибутов и так далее. (Отступление — почему не вызвать obj = subdtype.__new__(...? Потому что у нас может не быть метода __new__ с той же сигнатурой вызова).
Роль __array_finalize__
__array_finalize__ — это механизм, предоставляемый NumPy, позволяющий подклассам обрабатывать различные способы создания новых экземпляров.
Помните, что экземпляры подклассов могут появляться тремя способами:
- явный вызов конструктора (
obj = MySubClass(params)). Это вызовет обычную последовательностьMySubClass.__new__и (если он существует)MySubClass.__init__. - Преобразование представления
- Создание нового из шаблона
Наш метод MySubClass.__new__ вызывается только в случае явного вызова конструктора, поэтому мы не можем полагаться на MySubClass.__new__ или MySubClass.__init__ для обработки преобразования представления и создания нового из шаблона. Оказывается, что MySubClass.__array_finalize__ вызывается для всех трёх методов создания объектов, поэтому именно здесь обычно обрабатываются задачи создания объектов.
- Для явного вызова конструктора наш подкласс должен создать новый экземпляр ndarray своего собственного класса. На практике это означает, что нам, авторам кода, нужно сделать вызов
ndarray.__new__(MySubClass,...), иерархически подготовив вызовsuper(MySubClass, cls).__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(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 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 отличается для трёх способов создания экземпляров:
- При вызове из явного конструктора,
objявляетсяNone - При вызове из преобразования представления,
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(InfoArray, subtype).__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, включая передачу буферов, форм и так далее. Мы, скорее всего, хотели бы, чтобы конструктор мог принимать уже сформированный ndarray из обычных вызовов NumPy к np.array и возвращать объект.
Более реалистичный пример — добавление атрибута к существующему массиву
Вот класс, который принимает существующий стандартный 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
Новая функция в версии 1.13.
Подкласс может переопределить поведение при выполнении numpy ufunc, переопределив метод по умолчанию ndarray.__array_ufunc__. Этот метод выполняется вместо 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 the function. This includes any ``out`` arguments, which are always contained in a tuple.
Типичная реализация преобразует входные или выходные данные, являющиеся экземплярами собственного класса, передаёт всё суперклассу с помощью super(), и, наконец, возвращает результаты после возможного преобразования обратно. Пример, взятый из тестового случая test_ufunc_override_with_super в core/tests/test_umath.py, выглядит следующим образом.
input numpy as np
class 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.nout
info = {}
if in_no:
info['inputs'] = in_no
if out_no:
info['outputs'] = out_no
results = super(A, self).__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, имеющего переопределение. Если вы используете super как в примере, ndarray.__array_ufunc__ заметит, что b имеет переопределение, что означает, что он не может вычислить результат сам. Таким образом, он вернёт NotImplemented, и наш класс A тоже. Затем управление передаётся b, который либо знает, как с этим справиться и производит результат, либо нет и возвращает NotImplemented, вызывая TypeError.
Если вместо этого мы заменим наш вызов super на getattr(ufunc, method), то мы фактически делаем np.add(a.view(np.ndarray), b). Опять же, будет вызван B.__array_ufunc__, но теперь он видит ndarray как другой аргумент. Вероятно, он будет знать, как с этим справиться, и вернёт нам новый экземпляр класса B. Наш пример класса не настроен на обработку этого, но это может быть лучшим подходом, если, например, нужно повторно реализовать MaskedArray с помощью __array_ufunc__.
Наконец: если метод super подходит для данного класса, преимущество его использования заключается в том, что он помогает в построении иерархии классов. Например, предположим, что наш другой класс B также использовал super в своей реализации __array_ufunc__, и мы создали класс C, зависящий от обоих, т.е., class C(A, B) (для простоты без другого переопределения __array_ufunc__). Тогда любой ufunc над экземпляром C перейдёт к A.__array_ufunc__, вызов super в A перейдёт к B.__array_ufunc__, а вызов super в B перейдёт к ndarray.__array_ufunc__, что позволит A и B взаимодействовать.
__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):
print('In __array_wrap__:')
print(' self is %s' % repr(self))
print(' arr is %s' % repr(out_arr))
# then just call the parent
return 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_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) вызвал метод __array_wrap__ с аргументами self как obj, и out_arr как (ndarray) результат сложения. В свою очередь, метод по умолчанию __array_wrap__ (ndarray.__array_wrap__) преобразовал результат в класс MySubClass, и вызвал __array_finalize__ — отсюда и копирование атрибута info . Всё это произошло на уровне C.
Но мы могли бы сделать всё, что захотели:
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__ для нашего подкласса, мы можем настроить вывод от ufunc. Метод __array_wrap__ требует self, затем аргумент — результат ufunc — и необязательный параметр context. Этот параметр возвращается ufunc в виде 3-элементного кортежа: (имя ufunc, аргументы ufunc, область ufunc), но не устанавливается другими функциями numpy. Хотя, как показано выше, можно поступить иначе, __array_wrap__ должен возвращать экземпляр содержащего класса. Обратитесь к подклассу маскированного массива для реализации.
В дополнение к __array_wrap__, который вызывается при выходе из ufunc, также есть метод __array_prepare__, который вызывается при входе в ufunc, после создания выходных массивов, но до выполнения каких-либо вычислений. Реализация по умолчанию ничего не делает, кроме передачи массива. __array_prepare__ не должен пытаться получить доступ к данным массива или изменить размер массива, он предназначен для установки типа выходного массива, обновления атрибутов и метаданных и выполнения любых проверок на основе входных данных, которые могут потребоваться перед началом вычислений. Как и __array_wrap__, __array_prepare__ должен возвращать ndarray или подкласс от него, либо вызывать ошибку.
Extra gotchas - методы custom __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 — есть некоторые исключения, см. книгу numpy для получения более подробной информации.
Атрибут base полезен для определения, имеем ли мы представление или исходный массив. Это, в свою очередь, может быть полезно, если нам нужно знать, нужно ли выполнять определённую очистку при удалении подклассированного массива. Например, мы можем выполнить очистку только в случае удаления исходного массива, но не представлений. Для примера того, как это может работать, обратитесь к классу memmap в numpy.core.
Подклассы и совместимость с последующими версиями
При подклассировании ndarray или создании типов уток, имитирующих интерфейс ndarray, вы сами решаете, насколько ваши API будут соответствовать API numpy. Для удобства многие функции numpy, имеющие соответствующий метод ndarray (например, sum, mean, take, reshape работают, проверяя, есть ли у первого аргумента функции метод с таким же именем. Если он существует, метод вызывается вместо приведения аргументов к массиву 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 он передаст неожиданные аргументы out и keepdims, вызвав TypeError.
Если вы хотите сохранить совместимость с numpy и его последующими версиями (которые могут добавить новые ключевые аргументы), но не хотите отображать все аргументы numpy, подпись вашей функции должна принимать **kwargs. Например:
def sum(self, axis=None, dtype=None, **unused_kwargs): ...
Этот объект снова совместим с np.sum, потому что все лишние аргументы (т.е. ключевые слова, которые не являются axis или dtype будут скрыты в параметре **unused_kwargs.
© 2005–2020 NumPy Developers
Licensed under the 3-clause BSD License.
https://numpy.org/doc/1.18/user/basics.subclassing.html