NumPy Universal Functions(ufunc)完全指南:从逐元素运算到广播、类型转换与错误处理
【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy
Universal functions(简称 ufunc)是 NumPy 高性能数值计算的核心抽象。本文以官方文档 doc/source/user/basics.ufuncs.rst 为主线,结合仓库中的 ufunc 参考手册 与numpy/_core下的源码实现,系统讲解 ufunc 的定义、五大实例方法(reduce、accumulate、reduceat、outer、at)、输出类型确定规则、广播机制、类型转换(casting)规则、内部缓冲区以及浮点错误处理。读完本文,你将掌握 ufunc 的完整行为模型,并能利用frompyfunc创建自定义 ufunc、用seterr/errstate精确控制异常处理、用can_cast预判类型提升结果。
什么是 ufunc:逐元素运算的向量化封装
Universal function(ufunc)是作用于ndarray的逐元素(element-by-element)函数,支持数组广播、类型转换等标准特性。本质上,ufunc 是一个针对"固定数量输入、固定数量输出"函数的向量化(vectorized)包装器。
在 NumPy 中,ufunc 是numpy.ufunc类的实例,大量内建函数由编译后的 C 代码实现。基本 ufunc 操作标量元素,而广义 ufunc(generalized ufunc,简称 gufunc)的基本元素是子数组(向量、矩阵等),并在其他维度上进行广播。最典型的对比是逐元素的np.add与作用于向量/矩阵的np.matmul:
>>> import numpy as np >>> a = np.arange(6).reshape(3, 2) >>> a array([[0, 1], [2, 3], [4, 5]]) >>> np.add(a, a) # 逐元素加法 array([[ 0, 2], [ 4, 6], [ 8, 10]]) >>> np.matmul(a, a.T) # 矩阵乘法 (3x2) @ (2x3) -> (3x3) array([[ 1, 3, 5], [ 3, 13, 23], [ 5, 23, 41]])最简单的 ufunc 用法是算术运算符,+运算符底层即调用对应的加法 ufunc:
>>> np.array([0,2,3,4]) + np.array([1,1,-1,2]) array([1, 3, 2, 6])对于标量 ufunc,输入输出都是标量;对于广义 ufunc,输入输出则是子数组。想要深入了解 gufunc 的签名(signature)机制,可参阅 doc/source/reference/ufuncs.rst 中的signature/axes/axis参数说明。
用 frompyfunc 创建自定义 ufunc
除了内建 ufunc,你还可以通过工厂函数numpy.frompyfunc将任意 Python 函数包装成 ufunc 实例。该函数在底层由 C 实现,入口定义于 numpy/_core/src/umath/umathmodule.c 的ufunc_frompyfunc,并注册在 multiarray 模块中。其签名(见 numpy/_core/multiarray.pyi)支持指定输入个数、输出个数(以及可选的恒等元素identity):
def frompyfunc(func, nin, nout, *, identity=None)例如,把一个接受两个 Python 标量的函数包装成 ufunc:
>>> def my_add(a, b): ... return a + b >>> uf = np.frompyfunc(my_add, 2, 1) >>> uf([1, 2, 3], [10, 20, 30]) array([11, 22, 33], dtype=object)注意frompyfunc生成的对象 ufunc 输出 dtype 为object;如需更高效的数值型自定义 ufunc,可参考 c-info.ufunc-tutorial 编写 C 扩展。
Ufunc 的五大方法:reduce 系列与原地操作
所有 ufunc 都有5 个方法:4 个 reduce 类方法(reduce、accumulate、reduceat、outer)和 1 个原地操作方法(at)。这些方法只对"接收两个输入、返回一个输出"的标量 ufunc 有意义(其内层循环作用于单个标量值)。对不满足条件的 ufunc 调用这些方法会抛出ValueError;对输出多于一个的 ufunc 调用reduce会抛出TypeError(除非该 ufunc 的循环实现注册了专用的 reduction 循环,此时reduce也可用,但返回元组)。
以np.add为例,其方法正常工作:
>>> np.add.reduce([1, 2, 3]) 6而np.divmod返回两个输出(商与余数)且未注册 reduction 循环,调用其方法即报错:
>>> np.divmod.reduce([1, 2, 3]) Traceback (most recent call last): ... TypeError: divmod.reduce is not supported: the resolved loop does not register a reduction loop若某个多输出 ufunc 的循环确实注册了 reduction 循环,则其reduce返回一个"每个输出一个数组"的元组,initial参数既可以传单个值(广播到每个输出),也可以传一个"每个输出一个值"的元组。如何为自定义 ufunc 添加 reduction 循环,参见 c-info.reduction-loop-tutorial。
reduce 类的通用关键字:axis、dtype、out
所有 reduce 类方法都接受axis、dtype、out关键字,且输入数组维度必须 ≥ 1。
axis:指定归约沿哪个轴进行(负值从后往前计数)。对reduce而言,它还可以是int元组(同时归约多个轴)或None(归约所有轴):
>>> x = np.arange(9).reshape(3,3) >>> x array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) >>> np.add.reduce(x, 1) # 沿轴 1 归约 array([ 3, 12, 21]) >>> np.add.reduce(x, (0, 1)) # 同时归约两个轴 36dtype:解决"结果放不进原数组 dtype"这一常见问题。例如对单字节整数数组求和,结果可能溢出int8。dtype允许你指定归约运算所用的数据类型(即输出类型),从而保证精度足够:
>>> x.dtype dtype('int64') >>> np.multiply.reduce(x, dtype=np.float64) array([ 0., 28., 80.])有一个自动提升例外:若对add/multiply做归约而未指定dtype,且输入为比numpy.int_更小的整数(或布尔)类型,则会内部提升到int_(或uint)。其余情况,调整归约类型的责任基本在你身上。
out:提供输出数组(多输出 ufunc 提供输出数组元组)。若给定了out,dtype只影响内部计算,最终结果会写入out并按其 dtype 存储:
>>> y = np.zeros(3, dtype=np.int_) >>> y array([0, 0, 0]) >>> np.multiply.reduce(x, dtype=np.float64, out=y) array([ 0, 28, 80])注:在 doc/source/reference/ufuncs.rst 的"Optional keyword arguments"一节还说明了
out的更多细节:out可以是元组(每输出一项,允许None表示由 ufunc 分配);单输出 ufunc 也可直接传单个数组;默认out=None时创建未初始化数组,若结果是零维则转换为标量,传out=...(即out=Ellipsis)可避免该转换。
ufunc.at:基于高级索引的原地操作
第五个方法numpy.ufunc.at允许使用高级索引执行原地操作。在用到高级索引的维度上不使用内部缓冲(buffering),因此高级索引可以多次列出同一元素,操作将基于该元素上一次操作的结果继续执行。例如:
>>> a = np.zeros(5) >>> np.add.at(a, [1, 1, 1], 1) # 对索引 1 累加 3 次 >>> a array([0., 3., 0., 0., 0.])仓库中 numpy/_core/tests/test_mem_overlap.py 的test_ufunc_at_manual对ufunc.at在各种索引下的行为(含副本与重叠)做了系统性验证;test_custom_dtypes.py 则验证了自定义 dtype 上multiply.at的语义。
输出类型确定:ndarray、array scalar 与子类包装
如果 ufunc(或其方法)的输入参数是ndarray,则输出也是ndarray。唯一例外是结果为零维时会被转换为array scalar;传入out=...或out=Ellipsis可以避免这种转换。
如果部分或全部输入不是ndarray,则输出也不一定是ndarray:
- 若任一输入定义了
__array_ufunc__方法,控制权将完全交给该方法,即发生ufunc 覆盖(override),详见 doc/source/reference/arrays.classes.rst。 - 若没有任何输入覆盖 ufunc,则所有输出数组会交给那些定义了
__array_wrap__方法、且在所有输入(除ndarray与标量外)中__array_priority__最高的输入对象处理。ndarray的默认__array_priority__为 0.0,子类型默认为 0.0,而matrix为 10.0。仓库中的实际例子:numpy/_core/memmap.py的memmap将__array_priority__设为 -100.0,并自定义__array_wrap__;numpy/_core/defchararray.py的chararray也重写了__array_wrap__。这三个协议的完整文档位于 numpy/_core/_add_newdocs.py 的ndarray条目(__array_ufunc__、__array_wrap__、__array_priority__)。 - 所有 ufunc 都可以接收输出参数,输出必须是数组或其子类;必要时结果会被转换为所提供输出数组的 dtype。如果输出对象自身定义了
__array_wrap__,则优先调用它,而不是输入上找到的那个。
相关测试可参考 numpy/_core/tests/test_multiarray.py 中的test_ufunc_binop_bad_array_priority等用例,它们验证了不同__array_priority__下二元运算的返回类型行为。
广播(Broadcasting):维度为 1 时步长为 0
每个 ufunc 都通过对输入执行核心函数的逐元素运算来产生数组输出(元素通常是标量,对 gufunc 则可能是向量或更高阶子数组)。标准广播规则被应用,使得形状不完全相同的输入仍可参与运算。
按照这些规则,如果输入形状的某个维度大小为 1,则沿该维度的所有计算都使用该维度的第一个数据元素。换句话说,ufunc 的步进机制(stepping machinery)在该维度上不步进——即该维度的stride 为 0。这种"stride 置 0"的实现方式使得广播不需要复制数据,而是直接复用同一内存位置,这正是广播高效的原因。详细理论可参见 theory.broadcasting。
类型转换(Casting)规则:内层循环与 can_cast 表
内层循环与 .types 属性
每个 ufunc 的核心是一个一维 strided 循环(inner loop),为特定类型组合实现实际函数。创建 ufunc 时,它携带一个静态的内层循环列表及对应的类型签名列表。ufunc 机制根据输入类型从该列表中选择合适的内层循环。你可以通过 ufunc 的.types属性查看哪些类型组合定义了内层循环及其输出类型(输出类型使用字符代码简写,如'dd->d'表示双精度输入输出双精度)。
安全转换与选择算法
当 ufunc 没有针对所给输入类型的内层循环实现时,就必须对部分或全部输入做类型转换。算法会搜索一个"所有输入都能**安全地(safely)**转换过去"的类型签名,选中内部列表里第一个匹配项,在完成所有必要转换后执行。注意:ufunc 过程中产生的内部拷贝(即使为了转换)被限制在内部缓冲区大小之内(缓冲区大小可由用户设置)。
NumPy 的 ufunc 支持混合类型签名——同一个 ufunc 可以同时支持浮点与整数输入,例如numpy.ldexp。
用 can_cast 查看"安全转换"表
上述转换规则本质上等价于"何时一种 dtype 可以安全地转换为另一种 dtype"。该问题可用numpy.can_cast(fromtype, totype)在 Python 中直接判断。can_cast的实现位于 numpy/_core/multiarray.py(默认casting="safe"),与之配套的类型提升 API 还有numpy.result_type、numpy.promote_types与numpy.min_scalar_type(自 NumPy 1.6.0 起用于封装输出类型确定机制),同样定义在该文件中。
以下代码输出 64 位系统上"可安全转换"表(结果依赖平台,32 位系统上整数类型尺寸不同,表格会略有差异):
>>> mark = {False: ' -', True: ' Y'} >>> def print_table(ntypes): ... print('X ' + ' '.join(ntypes)) ... for row in ntypes: ... print(row, end='') ... for col in ntypes: ... print(mark[np.can_cast(row, col)], end='') ... print() ... >>> print_table(np.typecodes['All']) X ? b h i l q n p B H I L Q N P e f d g F D G S U V O M m ? Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y - Y b - Y Y Y Y Y Y Y - - - - - - - Y Y Y Y Y Y Y Y Y Y Y - Y h - - Y Y Y Y Y Y - - - - - - - - Y Y Y Y Y Y Y Y Y Y - Y i - - - Y Y Y Y Y - - - - - - - - - Y Y - Y Y Y Y Y Y - Y l - - - - Y Y Y Y - - - - - - - - - Y Y - Y Y Y Y Y Y - Y q - - - - Y Y Y Y - - - - - - - - - Y Y - Y Y Y Y Y Y - Y n - - - - Y Y Y Y - - - - - - - - - Y Y - Y Y Y Y Y Y - Y p - - - - Y Y Y Y - - - - - - - - - Y Y - Y Y Y Y Y Y - Y B - - Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y Y - Y H - - - Y Y Y Y Y - Y Y Y Y Y Y - Y Y Y Y Y Y Y Y Y Y - Y I - - - - Y Y Y Y - - Y Y Y Y Y - - Y Y - Y Y Y Y Y Y - Y L - - - - - - - - - - - Y Y Y Y - - Y Y - Y Y Y Y Y Y - - Q - - - - - - - - - - - Y Y Y Y - - Y Y - Y Y Y Y Y Y - - N - - - - - - - - - - - Y Y Y Y - - Y Y - Y Y Y Y Y Y - - P - - - - - - - - - - - Y Y Y Y - - Y Y - Y Y Y Y Y Y - - e - - - - - - - - - - - - - - - Y Y Y Y Y Y Y Y Y Y Y - - f - - - - - - - - - - - - - - - - Y Y Y Y Y Y Y Y Y Y - - d - - - - - - - - - - - - - - - - - Y Y - Y Y Y Y Y Y - - g - - - - - - - - - - - - - - - - - - Y - - Y Y Y Y Y - - F - - - - - - - - - - - - - - - - - - - Y Y Y Y Y Y Y - - D - - - - - - - - - - - - - - - - - - - - Y Y Y Y Y Y - - G - - - - - - - - - - - - - - - - - - - - - Y Y Y Y Y - - S - - - - - - - - - - - - - - - - - - - - - - Y Y Y Y - - U - - - - - - - - - - - - - - - - - - - - - - - Y Y Y - - V - - - - - - - - - - - - - - - - - - - - - - - - Y Y - - O - - - - - - - - - - - - - - - - - - - - - - - - - Y - - M - - - - - - - - - - - - - - - - - - - - - - - - Y Y Y - m - - - - - - - - - - - - - - - - - - - - - - - - Y Y - Y注意两点:表格中的'S'、'U'、'V'(字节串/Unicode/void 等)虽然列在表中,但不能被 ufunc 直接操作。
标量-数组混合的特殊规则
混合标量-数组运算使用另一套转换规则:只有当标量属于与数组根本不同种类的数据(即处于 dtype 层级结构的不同分支)时,标量才会"提升"数组。这条规则让你可以在代码中放心使用标量常量(它们在 ufunc 中按 Python 类型解释),而不必担心标量常量的精度会强制你的大数组(低精度)被上转。
内部缓冲区:setbufsize 与逐线程配置
ufunc 内部使用缓冲区来处理三类数据:未对齐(misaligned)数据、字节交换(swapped)数据、以及需要从一种 dtype 转换为另一种 dtype 的数据。内部缓冲区的大小可按线程(per-thread)设置。最多会创建2 * (n_inputs + n_outputs)个指定大小的缓冲区来处理所有输入与输出。
- 缓冲区默认大小为 10,000 个元素。
- 只要所有输入数组小于缓冲区大小,这些"表现异常或类型错误"的数组会在计算前被整体拷贝。
- 调整缓冲区大小可能显著改变各类 ufunc 计算的完成速度。
设置缓冲区的简单接口是numpy.setbufsize(size),对应读取接口为numpy.getbufsize()。其实现位于 numpy/_core/_ufunc_config.py:
def setbufsize(size): if size < 0: raise ValueError("buffer size must be non-negative") old = _get_extobj_dict()["bufsize"] extobj = _make_extobj(bufsize=size) _extobj_contextvar.set(extobj) return old从源码可以看到,缓冲区大小存储在线程局部(_extobj_contextvar,一个 contextvar)的扩展对象字典中;setbufsize返回旧值,便于事后恢复。自 NumPy 2.0 起,缓冲区大小的作用域与numpy.errstate上下文绑定:退出with errstate():块时缓冲区大小也会一并恢复(numpy/_core/_ufunc_config.py 的setbufsizedocstring 中有明确示例)。
错误处理:浮点状态寄存器与 seterr 系列
ufunc 会触发硬件中的特殊浮点状态寄存器(例如除零)。只要平台支持,这些寄存器在计算过程中会被定期检查。错误处理同样是按线程控制的,可通过numpy.seterr和numpy.seterrcall配置。
numpy.seterr支持五类浮点异常与六种处理方式(详见 numpy/_core/_ufunc_config.py 的实现与文档):
| 异常类型 | 触发场景 |
|---|---|
divide | 除零(有限数相除得无穷) |
over | 溢出(结果大到无法表示) |
under | 下溢(结果太接近 0 而损失精度) |
invalid | 非法操作(结果不可表示,通常产生 NaN) |
| 处理方式 | 行为 |
|---|---|
ignore | 不采取任何行动 |
warn | 通过 Pythonwarnings模块发出RuntimeWarning(默认) |
raise | 抛出FloatingPointError |
call | 调用seterrcall指定的回调函数 |
print | 直接向stdout打印警告 |
log | 将错误记录到seterrcall指定的 Log 对象 |
示例:
>>> orig_settings = np.seterr(all='ignore') # 先设置到已知状态 >>> np.seterr(over='raise') {'divide': 'ignore', 'over': 'ignore', 'under': 'ignore', 'invalid': 'ignore'} >>> old_settings = np.seterr(all='warn', over='raise') >>> np.int16(32000) * np.int16(3) Traceback (most recent call last): File "<stdin>", line 1, in <module> FloatingPointError: overflow encountered in scalar multiply >>> np.seterr(**old_settings) # 恢复原设置numpy.seterrcall(func)用于设置call模式下的回调函数(f(err, flag),其中flag的低 4 位分别表示 divide/over/under/invalid)或log模式下带write(msg)方法的日志对象;配套的查询函数是geterr与geterrcall。回调与日志示例可直接在该模块的 docstring 中运行验证。
更推荐的做法是使用上下文管理器numpy.errstate(自 1.17 起也可作为装饰器使用;NumPy 2.0 起完全线程与 asyncio 安全,但同一实例不可重复进入,也不应用于装饰异步函数):
>>> with np.errstate(divide='ignore'): ... np.arange(3) / 0. array([nan, inf, inf]) >>> with np.errstate(invalid='raise'): ... np.sqrt(-1) Traceback (most recent call last): File "<stdin>", line 2, in <module> FloatingPointError: invalid value encountered in sqrtseterr/seterrcall/errstate都通过_make_extobj构造扩展对象并写入_extobj_contextvar(numpy/_core/_ufunc_config.py 第 100-108 行),因此它们是逐线程生效的。相关行为测试见 numpy/_core/tests/test_errstate.py,其中覆盖了非法模式值抛错、自引用数组等边界情况。
覆盖 ufunc 行为:array_ufunc协议
类(包括 ndarray 子类)可以通过定义特殊方法__array_ufunc__、__array_wrap__和__array_priority__来覆盖 ufunc 作用于它们时的行为。这是 NumPy 1.13 引入的正式分派协议:
__array_ufunc__(self, ufunc, method, /, *inputs, **kwargs):当输入包含该类的实例时,ufunc 的调用(包括其所有方法,如reduce、at)会被完整转发到这里,实现完全自定义的分派逻辑;__array_wrap__:决定结果的包装/返回类型;__array_priority__:在多个输入都实现包装方法时决定优先级。
关于该协议的完整细节(含 ndarray 子类示例),参见仓库中的 doc/source/reference/arrays.classes.rst 与 basics.dispatch,以及ndarray各协议的文档(numpy/_core/_add_newdocs.py 第 3139-3156 行附近)。
总结与实践建议
- 性能:优先使用内建 ufunc 而非 Python 循环;ufunc 的广播通过 stride 置 0 实现零拷贝,类型转换则通过受控的内部缓冲区完成。
- 归约精度:对
add/multiply之外的自定义归约,务必显式指定dtype,避免精度溢出;利用out复用预分配内存。 - 错误控制:用
errstate上下文(而非全局seterr)临时切换浮点异常行为,用seterrcall捕获错误详情。 - 类型预判:编写通用代码前,可用
np.can_cast、np.result_type、np.promote_types(numpy/_core/multiarray.py)推演类型提升结果,避免意外上转。 - 扩展:需要自定义逐元素函数时,
np.frompyfunc是最快上手的方式;追求性能则参考 c-info.ufunc-tutorial 编写 C 扩展,并可通过注册专用 reduction 循环让自定义 ufunc 支持reduce(c-info.reduction-loop-tutorial)。
进一步阅读:Ufunc 参考手册(含where、axes、axis、keepdims、casting、order等可选关键字参数的完整说明)、广播基础、dtype 层级与字符代码。
【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考