NumPy 改进解读:`nanmean`/`nanstd`/`nanvar` 不再因只读归约结果而失败
2026/9/20 7:11:42 网站建设 项目流程
  • 科学计算
  • 数据分析

【免费下载链接】numpy

The fundamental package for scientific computing with Python.

项目地址:https://gitcode.com/gh_mirrors/nu/numpy
点击查看免费下载

本篇文章基于 NumPy 仓库中的发布预告文档 31069.improvement.rst,详细解析np.nanmeannp.nanstdnp.nanvar三个 NaN 忽略型统计函数针对只读归约结果的一次重要行为改进:此前它们会抛出ValueError: output array is read-only,现在则自动分配输出并保持原有的就地除法所得 dtype。读完本文,你将理解该问题的产生根源、numpy.ma.MaskedArray全掩码场景下触发此 bug 的完整调用链,以及源码中是如何修复与回归验证的。

一、问题背景:就地除法撞上只读数组

在实现上,np.nanmeannp.nanstdnp.nanvar的核心计算策略是:先通过求和(np.sum)得到分子,再用非 NaN 元素计数作为分母做除法。为了节省一次临时数组分配,它们默认把除法直接做进求和结果这个临时数组里(in-place divide)。

问题在于:部分归约操作返回的数组是只读的。当尝试对只读数组做就地除法时,NumPy 会抛出:

ValueError: output array is read-only

而不是正常返回结果。这正是本改进之前np.nanmeannp.nanstdnp.nanvar在特定场景下失败的根因(参见 issue gh-29117,改进合入的 PR 为 gh-31069)。

最容易触发的场景:全掩码的 MaskedArray

最容易踩到这个坑的是numpy.ma.MaskedArray:当一个掩码数组的所有元素都被掩码(all masked)时,numpy.ma会把归约结果收敛为全局唯一的只读常量np.ma.masked。该常量定义于 numpy/ma/core.py:

masked = masked_singleton = MaskedConstant()

也就是说,对全掩码数组执行归约,得到的np.ma.masked是一个flags.writeable == False的只读对象,nanmean等函数原本试图就地除进去,于是直接失败。

二、源码修复:_divide_by_count的只读回退分支

本改进的核心修复集中在私有辅助函数_divide_by_count,位于 numpy/lib/_nanfunctions_impl.py。该函数的职责是:计算a/b并忽略无效结果(通过np.errstate(invalid='ignore', divide='ignore')抑制除零与无效值告警),且默认尽量复用a所在缓冲区以做到就地除法。

修复的关键逻辑(numpy/lib/_nanfunctions_impl.py):

with np.errstate(invalid='ignore', divide='ignore'): if isinstance(a, np.ndarray): if out is None: # `a` is normally a temporary we can divide into, but # some reductions return a read-only result (gh-29117). # Allocate then, keeping the dtype the in-place divide gives. if not a.flags.writeable: return np.divide(a, b, dtype=a.dtype, casting='unsafe') return np.divide(a, b, out=a, casting='unsafe') else: return np.divide(a, b, out=out, casting='unsafe')

改动要点一目了然:

  1. 先检查可写性:当out is None(即走就地除法路径)时,先检查a.flags.writeable
  2. 只读则新分配:若a只读,改调np.divide(a, b, dtype=a.dtype, casting='unsafe'),由 ufunc 自行分配一块新输出数组;
  3. 保持 dtype 语义:无论是否回退,显式传入dtype=a.dtypecasting='unsafe',确保结果 dtype 与原先就地除法完全一致,不会意外提升为float64。这一点对float32等输入尤为重要。

从注释与实现可见,a通常是一个临时数组(求和结果),正常情况就地除进去是安全的;只有遇到 gh-29117 这类返回只读归约结果的场景才需要分配新数组——这正是本改进前后行为差异的精确边界。

三、三个函数的完整调用链

_divide_by_count被三个公开函数共用,因此一处修复同时覆盖三者:

1.np.nanmean

定义于 numpy/lib/_nanfunctions_impl.py。算法分四步:

cnt = np.sum(~mask, axis=axis, dtype=np.intp, keepdims=keepdims, where=where) tot = np.sum(arr, axis=axis, dtype=dtype, out=out, keepdims=keepdims, where=where) avg = _divide_by_count(tot, cnt, out=out) isbad = (cnt == 0) if isbad.any(): warnings.warn("Mean of empty slice", RuntimeWarning, stacklevel=2)
  • _replace_nan(a, 0)把 NaN 替换为 0 并生成掩码;
  • cnt统计非 NaN 元素个数(dtype=np.intp);
  • tot是求和结果,随后作为_divide_by_count的分子a
  • 若某切片cnt == 0(全 NaN),发出RuntimeWarning并返回 NaN。

tot是只读数组时,此前第 5 步会炸出ValueError,现在则由_divide_by_count内部自动分配新数组。

2.np.nanvar

定义于 numpy/lib/_nanfunctions_impl.py。它内部会两次调用_divide_by_count

  • 第一次计算均值(L1842-L1844):
avg = np.sum(arr, axis=axis, dtype=dtype, keepdims=_keepdims, where=where) avg = _divide_by_count(avg, cnt)
  • 第二次用自由度dof = cnt - ddof计算方差(L1866-L1867):
dof = cnt - ddof var = _divide_by_count(var, dof)

注意nanvar在两次除法之间还会用np.subtract(arr, avg, out=arr, casting='unsafe', where=where)把均值就地减进原数组,并对复数输入用arr.conj()计算模平方(L1846-L1852),因此它比nanmean的中间步骤更多、更容易踩到只读缓冲区。

3.np.nanstd

定义于 numpy/lib/_nanfunctions_impl.py,标准差的实现直接建立在方差之上:nanstd会委托给nanvar的计算流程,随后对结果开方。因此_divide_by_count的修复同样自动传导到nanstd。它在参数上额外支持ddof(默认0)、correction(NumPy 2.0 起提供、与ddof二选一)以及mean(预传均值避免重复计算),这些参数最终都会影响dof的取值,进而决定除法分母。

四、行为变化对照与 dtype 保证

场景改进前改进后
普通可写临时数组就地除法,复用缓冲区就地除法,行为不变
归约结果只读(如全掩码MaskedArray归约为np.ma.maskedValueError: output array is read-only自动分配新数组,正常返回结果
float32输入回退分支通过dtype=a.dtype保持float32,不提升为float64
全 NaN / 自由度不足的切片照常发RuntimeWarning并返回 NaN行为不变

其中“保持 dtype”这一点有专门的单元测试守护:numpy/lib/tests/test_nanfunctions.py 中的test_divide_by_count_read_only

def test_divide_by_count_read_only(): # gh-29117: `a` is normally divided into in place, but some reductions # return a read-only array, so that is not always possible. a = np.array([6.0], dtype=np.float32) a.flags.writeable = False res = _divide_by_count(a, np.array([2], dtype=np.intp)) assert_equal(res, np.array([3.0], dtype=np.float32)) # the fallback must not promote the result to float64 assert_equal(res.dtype, np.float32)

该测试同时验证了两点:只读输入能正常出结果,且回退路径不会把float32意外提升为float64

五、MaskedArray 全掩码场景的回归测试

针对本文开头所述“全掩码数组”这一真实触发场景,仓库在 numpy/ma/tests/test_regression.py 中补充了回归测试test_nanfunctions_all_masked

def test_nanfunctions_all_masked(self): # see gh-29117. An all-masked array reduces to the read-only # `np.ma.masked`, which these used to try to divide into. a = np.ma.MaskedArray([np.nan, 3], mask=[True, True]) for f in (np.nanmean, np.nanstd, np.nanvar): assert_(np.ma.is_masked(f(a)), f.__name__)

它构造一个两个元素全被掩码的MaskedArray,断言三个函数在结果上调用np.ma.is_masked均为真——即函数不再抛异常,而是返回掩码语义下的正确结果。这与发布预告中的描述完全一致:

This is what madenp.nanmean,np.nanstdandnp.nanvarfail on aMaskedArraywhose values are all masked, sincenumpy.mareduces that to the read-onlynp.ma.masked.

六、对使用者的影响与建议

  • 行为兼容性:对于常规可写输入,三个函数的输出、dtype 与告警行为完全不变;仅在“归约结果为只读”这一此前必然报错的场景下,从报错变为正常返回,属于纯修复性质的改进,不引入破坏性变更。
  • numpy.ma用户:对全掩码数组(例如np.ma.masked_all或全掩码过滤后的结果)直接调用nanmean/nanstd/nanvar时,此前需要额外处理异常,现在可以放心调用,结果遵循掩码语义(返回masked)。
  • 性能提示:回退分支仅在只读输入下触发,会额外分配一块输出数组;正常路径仍是零额外分配的就地除法,性能不受影响。若你手动传入只读数组并追求极致性能,可预先自行np.copy后传入可写副本,但这不是必需操作。

七、小结

本改进以一处小而精准的修改(在_divide_by_count中检查a.flags.writeable并回退到自动分配)同时修复了nanmeannanvarnanstd三个函数在只读归约结果上的失败问题,并用两个针对性的单元测试(只读 dtype 保持 + 全掩码MaskedArray)锁定了修复效果。该改动可在当前仓库的以下位置继续深入查阅:

  • 发布预告:doc/release/upcoming_changes/31069.improvement.rst
  • 核心实现:_nanfunctions_impl.py 中的_divide_by_count(L204-L249)、nanmean(L958)、nanvar(L1803)、nanstd(L1885)
  • 单元测试:test_nanfunctions.py 与 test_regression.py
  • 只读常量定义:numpy/ma/core.py
  • 科学计算
  • 数据分析

【免费下载链接】numpy

The fundamental package for scientific computing with Python.

项目地址:https://gitcode.com/gh_mirrors/nu/numpy
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询