pandas 0.22.0 空值与全 NA 聚合语义变更详解:sum()/prod()与新增的min_count参数
【免费下载链接】pandasFlexible and powerful data analysis / manipulation library for Python, providing labeled data structures similar to R data.frame objects, statistical functions, and much more项目地址: https://gitcode.com/gh_mirrors/pa/pandas
导读
pandas 0.22.0(2017 年 12 月 29 日发布)是 0.21.1 之后的一个大版本,但它的核心改动只有一项——而且是唯一的API 破坏性变更:空(empty)或全 NA 的Series在sum()时默认返回0、prod()时默认返回1,同时为这两个方法引入了全新的min_count参数来控制"结果有效的非空值最低个数"。本文以官方发布说明 doc/source/whatsnew/v0.22.0.rst 为主线,结合当前仓库源码(pandas/core/generic.py、pandas/core/nanops.py 等)与测试用例,带你完整掌握这次语义变化的来龙去脉、受影响的所有场景(GroupBy、Resample、Rolling/Expanding),以及面向多版本兼容的实际应对方案。
读完本文,你将能够:解释min_count的默认值与取值含义;在Series、DataFrame、groupby、resample、rolling/expanding五种场景中精确控制聚合返回0/1还是NaN;并知道如何在自己的库中规避 pandas 0.21 的不一致行为。
一、变更背景:一次"部分回退"的决策
本次变更是对 pandas 0.21 行为的一次部分回退(partially reverted),其历史脉络如下:
- 在 pandas 0.21 之前,全 NA 序列的求和结果依赖是否安装了 bottleneck 库而出现不一致(详见 doc/source/whatsnew/v0.21.0.rst 中的相关说明)。0.21 修复了这一长年遗留的不一致问题,但顺带把空序列的
sum()/prod()也一并改成了NaN。 - 社区反馈认为:空序列与全 NA 序列的求和返回
NaN过于激进。于是 0.22.0 基于反馈部分回退了 0.21 的改动——默认行为恢复为"空或全 NA 时sum返回0、prod返回1",同时用新参数min_count提供精确控制。
变更总结为三条核心规则:
- 空或全 NA 的
Series,sum()结果为0; - 空或全 NA 的
Series,prod()结果为1; - 新增
min_count参数:当非 NA 值的个数少于min_count时,结果为 NA;默认值为0。要恢复 0.21 的NaN行为,使用min_count=1。
从当前源码可以确认这一设计延续至今。在 pandas/core/generic.py#L11849-L11910 中,sum与prod共用_min_count_stat_function辅助方法,签名均为:
def sum(self, *, axis=0, skipna=True, numeric_only=False, min_count=0, **kwargs): def prod(self, *, axis=0, skipna=True, numeric_only=False, min_count=0, **kwargs):两者最终都进入self._reduce(...),把min_count一路透传给底层nanops.nansum/nanops.nanprod。也就是说,min_count是Series与DataFrame在共享基类NDFrame层面统一实现的公共 API,这也解释了为什么它同时作用于分组、重采样等所有派生场景。
二、基础语义:Series.sum()与Series.prod()的新默认值
2.1 求和(sum)
空序列与全 NA 序列的默认求和结果从NaN变为0.0:
>>> import numpy as np >>> import pandas as pd # pandas 0.21.x pd.Series([]).sum() # nan pd.Series([np.nan]).sum() # nan # pandas 0.22.0 >>> pd.Series([]).sum() 0.0 >>> pd.Series([np.nan]).sum() 0.0官方文档特别指出,这个默认行为与pandas 0.20.3 + bottleneck的表现一致,也等价于 NumPy 对空数组与全 NA 数组的np.nansum行为。也就是说,0.22.0 实际上是把"无缺失值参与运算时的恒等元"语义(求和的恒等元是 0,求积的恒等元是 1)恢复为默认。
需要NaN(即 0.20.3 无 bottleneck 或 0.21.x 的默认行为)时,使用min_count=1:
>>> pd.Series([]).sum(min_count=1) nan2.2 与skipna的关系
由于sum()默认skipna=True(跳过 NA),一个全 NA 序列在概念上等价于一个"先剔除所有 NA 后再求和"的空序列。因此:
>>> pd.Series([np.nan]).sum(min_count=1) # skipna=True 是默认值 nan这与pd.Series([]).sum(min_count=1)的结果一致。理解这一点,就能把握住整个新语义的数学本质:先按skipna规则确定参与计算的"有效值集合",再比较有效值个数与min_count。
2.3 求积(prod)
prod()遵循与sum()完全相同的规则,只是恒等元为1:
>>> pd.Series([]).prod() 1.0 >>> pd.Series([np.nan]).prod() 1.0 >>> pd.Series([]).prod(min_count=1) nan2.4min_count的精确定义
min_count指的是非空(non-null)值的最少个数:只有非 NA 值个数达到该阈值,求和/求积结果才有效;否则返回 NA。默认值0意味着"任何情况下(包括空序列)都返回数值结果"。
在源码层面,这个判定逻辑集中在 pandas/core/nanops.py#L1914-L1994 的两个函数中:
_maybe_null_out():当min_count > 0时,如果某一维度(axis)上非空值个数不足,就把该位置的结果替换为NaN(数值类型)、None(非数值类型)或iNaT(datetimelike 类型);check_below_min_count():核心判定"非空值个数 < min_count"即返回True,其中non_nulls = mask.size - mask.sum()(有 mask 时)或np.prod(shape)(无缺失时)。
nansum内部(pandas/core/nanops.py#L775-L846)在求和之前先对缺失位置填充fill_value=0,因此空/全 NA 输入自然求和得0,随后再经由_maybe_null_out按min_count决定是否"抹掉"结果。这一实现路径保证了0这个默认返回值的稳定性。
仓库中的参数化测试对min_count与skipna的全部组合做了覆盖,例如 pandas/tests/reductions/test_reductions.py#L615-L705 中依次断言min_count=0与min_count=1、skipna=True与skipna=False下sum/prod的结果,以及在 pandas/tests/reductions/test_reductions.py#L1363-L1364 中验证"显式更大的min_count依然被尊重"(skipna=False, min_count=5时结果为 NA)。
2.5DataFrame同样受影响
由于实现位于共享基类,DataFrame.sum()与DataFrame.prod()也同步应用新默认值(按轴逐列/逐行聚合时,全 NA 的列/行返回0或1),并使用同一个min_count参数。
三、受影响场景一:按 Categorical 分组求和
分组键为Categorical且observed=False(默认)时,聚合结果会包含没有观测值的类别。在 0.21 中这些未观测类别返回NaN;0.22.0 起,求和返回0、求积返回1:
# pandas 0.21.x grouper = pd.Categorical(['a', 'a'], categories=['a', 'b']) pd.Series([1, 2]).groupby(grouper, observed=False).sum() # a 3.0 # b NaN # dtype: float64 # pandas 0.22.0 >>> grouper = pd.Categorical(['a', 'a'], categories=['a', 'b']) >>> pd.Series([1, 2]).groupby(grouper).sum() a 3 b 0 dtype: int64注意输出还发生了 dtype 变化:0.21 中因存在NaN而被迫升为float64,0.22.0 中未观测类别直接补0,因此保持整数类型int64,这实际上是额外的一个 dtype 红利。
要恢复 0.21 的NaN行为,给sum()传入min_count>=1:
>>> pd.Series([1, 2]).groupby(grouper).sum(min_count=1) a 3.0 b NaN dtype: float64当前源码中,GroupBy.sum()的签名(pandas/core/groupby/groupby.py#L2766-L2773)为sum(numeric_only=False, min_count=0, skipna=True, engine=None, engine_kwargs=None),其 docstring 明确写着:"若非 NA 值少于min_count,结果将为 NA"。默认值0正是"未观测类别补 0"这一行为得以成立的关键——它被透传到_cython_agg_general后的底层分组聚合中,由 Cython 实现按与nansum相同的规则处理缺失组。
四、受影响场景二:Resample 重采样
4.1 全 NA 桶(bin)的默认变化
重采样后,若某个时间桶内全为 NA,其求和结果从NaN变为0,求积从NaN变为1:
s = pd.Series([1, 1, np.nan, np.nan], index=pd.date_range("2017", periods=4)) # pandas 0.21.x s.resample('2d').sum() # 2017-01-01 2.0 # 2017-01-03 NaN # Freq: 2D, dtype: float64 # pandas 0.22.0 >>> s.resample("2d").sum() 2017-01-01 2.0 2017-01-03 0.0 Freq: 2D, Length: 2, dtype: float64恢复 0.21 行为同样只需min_count>=1:
>>> s.resample("2d").sum(min_count=1) 2017-01-01 2.0 2017-01-03 NaN Freq: 2D, Length: 2, dtype: float644.2 上采样(upsampling)尤其值得警惕
重采样场景中最隐蔽的影响是上采样:即使原始序列完全有效,上采样也会在新增的时间点上人为引入缺失值,从而把原本不受影响的序列也卷入新语义中:
idx = pd.DatetimeIndex(["2017-01-01", "2017-01-02"]) # pandas 0.21.x pd.Series([1, 2], index=idx).resample('12H').sum() # 2017-01-01 00:00:00 1.0 # 2017-01-01 12:00:00 NaN # 2017-01-02 00:00:00 2.0 # Freq: 12H, dtype: float64 # pandas 0.22.0 >>> pd.Series([1, 2], index=idx).resample("12H").sum() 2017-01-01 00:00:00 1 2017-01-01 12:00:00 0 2017-01-02 00:00:00 2 Freq: 12H, Length: 3, dtype: int64这里除了全 NA 中间桶从NaN变为0,还可以再次观察到 dtype 从float64降为int64的连带效果。如需保持NaN语义(例如后续要配合缺失值填充管道),显式传入min_count=1即可:
>>> pd.Series([1, 2], index=idx).resample("12H").sum(min_count=1) 2017-01-01 00:00:00 1.0 2017-01-01 12:00:00 NaN 2017-01-02 00:00:00 2.0 Freq: 12H, Length: 3, dtype: float64在源码层面,Resampler.sum()(pandas/core/resample.py#L1108-L1164)与prod()(pandas/core/resample.py#L1168-L1222)的签名同样携带min_count: int = 0,并在 docstring 中写明"非 NA 值少于min_count时结果为 NA",随后经由self._downsample("sum", ...)进入与GroupBy共享的底层聚合链路。
五、受影响场景三:Rolling 与 Expanding(min_periods=0)
滚动与扩展窗口早已拥有与min_count语义相近的min_periods参数。本次变更中唯一改变的情形是:当min_periods=0且窗口内非 NA 值不足min_periods(即窗口全 NA 时),sum()从NaN变为0:
s = pd.Series([np.nan, np.nan]) # pandas 0.21.1 s.rolling(2, min_periods=0).sum() # 0 NaN # 1 NaN # dtype: float64 # pandas 0.22.0 >>> s.rolling(2, min_periods=0).sum() 0 0.0 1 0.0 dtype: float64需要特别强调:min_periods=None(默认)时的行为完全不变。此时min_periods等于窗口大小,窗口内有效值不足窗口大小时依旧返回NaN,与本变更无关。
仓库测试对min_periods=0的窗口语义有系统覆盖,例如 pandas/tests/window/test_expanding.py#L64(expanding(min_periods=0).sum())与 pandas/tests/window/test_base_indexer.py#L178(min_periods=0等价于count的讨论),可作为理解该行为的参考用例。
六、跨版本兼容策略:如何安全地依赖 pandas
本次变更带来的最大工程风险在于:同一段代码在 pandas 0.21 与 0.22 上运行会得到不同的结果,而如果同时维护空序列分支,行为会更加混乱。官方给出的兼容性建议非常干脆:
如果你的库需要跨多个 pandas 版本工作,最简单的办法是在依赖中排除 pandas 0.21。否则,你所有的
sum()调用都必须在求和前先检查Series是否为空。
6.1 setuptools(setup.py)
install_requires=['pandas!=0.21.*', ...]6.2 conda(environment.yml/ meta.yaml)
requirements: run: - pandas !=0.21.0,!=0.21.16.3 注意事项与边界
- 排除 0.21 只解决空序列场景的差异;
- 全 NA 序列的返回不一致问题在pandas 0.20.3 及更早版本中依然存在(这正是 0.21 最初试图修复的历史问题,见本文第一节的背景梳理)。因此如果你的用户群中还可能出现 0.20.3 及更早版本,单纯排除 0.21 是不够的,需要另行兜底(例如显式传
min_count并接受不同版本间的结果差异,或在聚合前主动规范化输入)。
七、总结:新语义速查表
| 场景 | pandas 0.21.x | pandas 0.22.0(默认) | 恢复 0.21 行为 |
|---|---|---|---|
空/全 NASeries.sum() | NaN | 0.0 | sum(min_count=1) |
空/全 NASeries.prod() | NaN | 1.0 | prod(min_count=1) |
Categorical 未观测类别的groupby.sum() | NaN(float64) | 0(int64) | sum(min_count=1) |
全 NA 桶的resample("2d").sum() | NaN | 0.0 | sum(min_count=1) |
上采样引入空桶的resample("12H").sum() | NaN(float64) | 0(int64) | sum(min_count=1) |
rolling(2, min_periods=0).sum()全 NA 窗口 | NaN | 0.0 | 行为由min_periods控制 |
min_count参数的引入是这次发布最核心的 API 财富:它把"求和/求积结果何时应该有效"的控制权完整交给了用户,其默认值0保证了代数恒等元语义(空和 → 0,空积 → 1)的一致性,且这一设计从 0.22.0 一直延续到当前主分支(pandas/core/generic.py#L11849、pandas/core/nanops.py#L1914)。
实践建议:在新代码中,凡是可能面对空序列或全 NA 序列的聚合,都显式写明min_count——需要数值恒等元用默认0,需要缺失信号用min_count=1(或按业务阈值放大,如min_count=2要求至少两个有效值)。这样既能获得 0.22.0 之后稳定一致的语义,也能让你的代码在未来的 pandas 版本中保持可预期。
【免费下载链接】pandasFlexible and powerful data analysis / manipulation library for Python, providing labeled data structures similar to R data.frame objects, statistical functions, and much more项目地址: https://gitcode.com/gh_mirrors/pa/pandas
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考