☰
pandas统计函数避坑:describe、groupby与缺失值口径
2026/10/27 15:54:52 网站建设 项目流程

pandas 数据统计函数这块,我见过最多的一种用法,就是拿到一份新表先甩一个df.describe(),瞄一眼均值和最大值,觉得"数据挺正常的",然后心安理得地往下写清洗逻辑。等业务方跑来问"为什么这个月的订单数是 0",再回头翻才发现,那一列全被读成了空值,sum()好心地给你返回了一个 0。这类事故跟代码水平关系不大,纯粹是对这些统计函数的默认口径没摸清楚。这篇就按我自己平时跑数据的顺序,把 pandas 里那些高频统计函数一个个拆开看:它们在算什么、默认跳过了什么、什么时候会骗你,以及怎么用一行参数把口径掰回来。适合已经会用groupby和agg、但对自己算出来的数字还没那么有底气的人看。

1. 先弄明白 describe 这张"体检单"到底统计了谁

describe()是所有人接触 pandas 统计的第一个函数,也是误解最深的一个。它的默认行为不是"给我看看这张表的全部情况",而是"把浮点和整数列挑出来,各算八个指标"。这两句话的差别,在你拿到一张几十列的宽表时会非常致命。

1.1 describe 默认只看数值列,剩下的直接装死

先跑一段代码把默认行为看清楚:

import pandas as pd import numpy as np df = pd.DataFrame({ "order_id": [1001, 1002, 1003, 1004, 1005], "amount": [120.0, 88.5, np.nan, 300.0, 45.0], "channel": ["app", "web", "app", "app", None], "ts": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-05", "2024-01-06"]), }) print(df.describe()) print(df.describe(include="all")) print(df.describe(percentiles=[0.1, 0.9]))

第一次print出来只有order_id和amount两行统计。channel是 object、ts是 datetime,都被默认排除在外。也就是说,一张表里只要某个关键列因为脏数据被读成了 object,它在describe()里就是隐形的,你会误以为这列没什么可看的。我个人的习惯是先df.info()看一遍 dtype,再决定给describe()传什么参数,include="all"和include="object"交替用,比无脑默认值靠谱得多。

另外几个容易被忽略的参数值得记一下:count那一行统计的是非空值数量,不是总行数,拿它和len(df)一比就能算出缺失率;percentiles默认是[0.25, 0.5, 0.75],你完全可以传[0.01, 0.5, 0.99]来对付长尾分布;exclude参数可以反向排除掉不想看的类型。对 datetime 列调describe(),返回的是最早时间、最晚时间和几个分位时间点,做数据覆盖范围检查时特别好用,比手写min()/max()省事。

1.2 dtype 决定了统计函数能给你什么,而不只是 describe

describe()的盲区其实只是表象,根子在 dtype。同样是"金额",存成 float64 时你能拿到均值、标准差、分位数;存成 object 时只能拿到 count、unique、top、freq,也就是众数和它的出现次数。这个差别会一路传导到后面所有统计函数上。

最典型的场景是从文件里读数据时混进了占位符。金额列里出现几个"-"或者"暂无",整列立刻退化成 object,你再调df["amount"].mean()得到的不是数字而是TypeError。这时候别急着astype(float),先把脏值捞出来看看长什么样:

mask = pd.to_numeric(df["amount"], errors="coerce").isna() & df["amount"].notna() print(df.loc[mask, "amount"].unique())

errors="coerce"会把转换不了的值变成 NaN,再用"转换后是 NaN 但原值不是 NaN"这个条件,就能精确定位到所有非法单元格。这套写法我几乎在每个项目的数据接入层都会写一遍,比盲目 dropna 安全得多——你至少知道自己丢掉了什么。

还有一个隐蔽的坑:nunique()默认dropna=True。也就是说,一列里如果有 200 个不同取值加一个空值,nunique()告诉你 200。做"渠道数量""城市覆盖数"这类指标时,是否把空值算作一个独立类别,业务上和口径上完全是两回事,别默认接受。

2. 缺失值是怎么悄悄改写统计结果的

统计函数里最容易埋雷的地方,永远是 NaN 的处理策略。pandas 的设计哲学是"尽量不让你因为 NaN 报错",代价就是它会在很多地方替你做出选择,而这个选择未必是你想要的。

2.1 skipna 默认开启,但"跳过"不等于"算对了"

sum()、mean()、median()、std()这一批聚合函数,默认都是skipna=True,也就是先把 NaN 剔掉再算。这个默认值在大部分时候是对的,但在两种情况下会让你算出一个看起来很正常的错误答案。

第一种是整列全空。Series([np.nan, np.nan]).sum()返回0.0,而mean()返回NaN。同样是全空,一个给你 0 一个给你 NaN,这种不一致经常让下游的报表逻辑翻车。要防这个,用min_count参数控制"至少有 N 个有效值才计算",不够就返回 NaN:

s = pd.Series([np.nan, 1.0, np.nan]) print(s.sum()) # 1.0 print(s.sum(min_count=1)) # 1.0 print(s.sum(min_count=2)) # nan —— 有效值不够,明确告诉你算不了

第二种是"平均值的平均"。很多人算整体客单价时,先按天算mean()再对结果求mean(),这跟直接对全量求mean()不是一个东西,因为每天的有效样本量不一样。正确做法要么用加权((sum/sum).sum()),要么把 NaN 计数也一起带上,让下游知道权重。我踩过一次这个坑:按门店算完均值再平均,结果跟财务口径差了百分之十几,排查了半天才反应过来是门店单量差异太大。

提示:count()只数非空值,size()数的是包含 NaN 在内的总行数。做缺失率统计时用1 - s.count() / s.size比s.isna().mean()更直观,但两者等价。

2.2 布尔列求和是把计数器,前提是别混进 NaN

布尔列做统计是 pandas 里性价比最高的技巧之一:sum()拿到的是 True 的个数,mean()拿到的是 True 的比例,一行代码顶三行filter + len。但一旦这列是object类型、或者里面混了 NaN(此时 dtype 往往变成 object),sum()就会尝试把值加起来,字符串会互相拼接,混合类型会直接抛TypeError。

稳妥的做法是先转成可空布尔类型再统计:

df["is_new"] = df["is_new"].astype("boolean") # 注意不是 bool,bool 不接受 NA print(df["is_new"].sum()) # 自动跳过 NA print(df["is_new"].mean()) # True 占比,NA 不计入分母

boolean(可空布尔)是 pandas 后来引入的扩展类型,跟 numpy 的bool不是一个东西,前者能装 NA。用bool强转遇到 NaN 会直接报错,这也是新手常卡住的地方。类似的还有Int64、string这些扩展 dtype,做严肃统计时我更倾向于用它们,因为"缺失"和"零"在语义上被彻底分开了。

3. 位置类统计:idxmax、mode、quantile 与 rank 各有各的脾气

算最大值、众数、分位数、排名这几个,逻辑上都不复杂,但每个都有一两个默认行为会让你在特定数据上得到意外结果。

3.1 idxmax 给的是索引标签,不是行号

idxmax()/idxmin()返回的是索引值,不是位置。如果索引是默认的RangeIndex,两者恰好一致,你很容易形成错误直觉;等到set_index("order_id")之后再用,返回的就变成订单号了。更需要注意的是两个边界情况:全 NaN 的列调idxmax()会抛ValueError: attempt to get argmax of an empty sequence,这在按分组批量计算时会让整个循环崩掉;索引有重复值时,它返回第一个命中的标签,后面的同名行拿不到。

真要取"最大值那一行",我一般这么写,顺手把防御也做了:

def top_row(g, col): if g[col].notna().sum() == 0: return None return g.loc[g[col].idxmax()] result = df.groupby("channel").apply(top_row, col="amount")

顺便提一句,别用argmax()系列取位置,那套 API 在新版本里已经陆续弃用了,而且遇到 NaN 的处理方式跟idxmax不一致,混着用只会给自己挖坑。

3.2 quantile 的插值方式会实打实改变答案

quantile()默认interpolation="linear",也就是在两个相邻样本之间做线性插值。数据量大的时候不同插值方式差别微乎其微,但样本量小、或者你在算极端分位(P99、P1)时,差异能到肉眼可见的程度。拿[1, 2, 3, 4]求中位数举例:

interpolation结果含义
linear(默认)2.5中间线性插值
lower2取下侧样本
higher3取上侧样本
midpoint2.5两侧平均
nearest2取最近样本

这套参数最大的用处不是自己算着玩,而是和外部系统对齐口径。做监控指标时,"P95 响应时间"到底按哪个插值算,你这边 200ms 对方那边 205ms,对不上账的时候翻一翻对方用的什么算法,八成就能解释清楚。另外quantile对 NaN 默认跳过,你没法通过参数控制是"跳过"还是"传染",需要自己先决定要不要dropna()。

3.3 rank 的 method 参数决定并列名次怎么排

rank()平时用得不多,但一做评分卡、排序打分就绕不开。它默认method="average",也就是并列的两行取平均名次。四种主流 method 的差异用一组数据就能看明白,对[10, 20, 20, 30]做升序排名:

method结果说明
average(默认)1, 2.5, 2.5, 4并列取平均
min1, 2, 2, 4并列都取下限
max1, 3, 3, 4并列都取上限
dense1, 2, 2, 3名次连续不跳号
first1, 2, 3, 4按出现顺序强行拉开

选哪个取决于你下游怎么用。要做"前 10 名"截断,min更安全,因为它保证并列的都进得去;要做连续等级分组(比如分成五档),dense才不会出现空档。还有个na_option参数管 NaN 怎么办:keep保留 NaN(默认)、top排最前、bottom排最后。默认的keep意味着排名结果里会留 NaN,你要是直接astype(int)就会炸,这点要注意。

4. std 和 corr:跟别的工具对不上账,八成栽在这两个地方

标准差和相关系数是最容易产生"我这边算出来跟别人不一样"的两类指标。原因基本集中在两个参数上:ddof和method。

4.1 pandas 的 std 默认除以 n-1,numpy 默认除以 n

这是 pandas 和 numpy 之间最经典的一处默认值分歧。pd.Series.std()默认ddof=1(样本标准差,分母 n-1),np.std()默认ddof=0(总体标准差,分母 n)。同一组数据[1, 2, 3, 4]:

import numpy as np import pandas as pd s = pd.Series([1, 2, 3, 4]) print(s.std()) # 1.29099... ddof=1 print(s.std(ddof=0)) # 1.11803... 与 np.std 一致 print(np.std(s.values)) # 1.11803... print(s.var()) # 1.66666... 同样 ddof=1

n=4 的时候两者差了十几个百分点,样本量上去之后差距会收敛到 1/n 的量级,但永远不会完全相等。所以对齐口径时不要只看"差不多",尤其在做小样本的 A/B 实验、把 pandas 结果和统计软件结果比对时,先把ddof统一了再看。我的习惯是:只要这个标准差会写进报告或传给下游系统,就显式写成s.std(ddof=1),让读代码的人一眼知道分母是什么。

4.2 corr 的三种算法和 cov 的量纲陷阱

df.corr()默认method="pearson",算的是线性相关,对异常值极其敏感——一个离群点就能把系数从 0.8 拉到 0.2。数据里有重尾、或者存在明显的非线性单调关系时,换成"spearman"(秩相关)会稳得多,代价是丢失了"幅度"信息。"kendall"算的是秩序一致性,样本量大的时候慢,一般用于层级少、并列多的场景。

几个必须知道的细节:corr()会自动只挑数值列参与计算,object 列被静默忽略,所以如果你的数值列因为脏值退化成了 object,它会在相关矩阵里凭空消失,看着像"这两列没关系"。min_periods参数控制一对列至少要有多少个有效配对才计算,默认值较低,数据稀疏时算出来的相关系数可能只基于三五个点,看上去很显著其实毫无意义。

还有一个更隐蔽的问题:pandas 计算相关矩阵用的是成对删除(pairwise deletion),每一对列各自剔掉自己的 NaN 行。这导致不同列对的有效样本量不一样,在极端情况下,算出来的矩阵甚至会不满足半正定。做因子分析、需要矩阵可分解的时候,得先做整行删除(df.dropna()[cols].corr())再算。

至于cov(),它和corr()的关系就是"有没有除以标准差"。协方差带量纲,金额乘数量的结果单位是"元·件",没人看得懂;相关系数被归一化到 -1 到 1,才有可比性。所以除非你要做矩阵运算,日常汇报用corr就够了。另外这两者都默认ddof=1,与上一节的标准差保持一致。

5. 分组聚合:把统计函数变成能交给业务的口径

前面那些函数在单列上跑一跑只是热身,真正产生业务价值的地方是groupby之后套统计。这一步的核心矛盾是:pandas 给你的是"计算能力",而业务要的是"口径",两者之间隔着一层聚合写法。

5.1 agg 的四种写法,选对了少写一半代码

同一个需求,agg有四种表达方式,可读性差别巨大:

g = df.groupby("channel") # 写法一:单函数 g["amount"].mean() # 写法二:函数列表,结果列是多层索引 g["amount"].agg(["mean", "median", "count"]) # 写法三:字典,列 -> 函数 g.agg({"amount": ["mean", "max"], "order_id": "count"}) # 写法四:命名聚合,最推荐 g.agg( order_cnt = ("order_id", "count"), amount_mean = ("amount", "mean"), amount_p95 = ("amount", lambda s: s.quantile(0.95)), new_ratio = ("is_new", "mean"), )

写法和写法二会生成 MultiIndex 列名,扁平化处理(df.columns = ["_".join(c) for c in df.columns])是每个人都要写一遍的样板代码。命名聚合直接把输出列名定好,还允许 lambda 和自定义函数混用,导出的表能直接交给业务方看,我是无条件推荐这一种的。用 lambda 的时候注意性能:quantile这类函数在百万行分组上会明显变慢,能提前算的就提前算。

聚合顺序也值得留意。agg里的各个函数是独立作用在原始数据上的,不存在"先算 A 再拿 A 算 B"的链式关系。如果你需要后者,比如"组内最大值减去组内均值",得用两次聚合再合并,或者在agg外再包一层。

5.2 agg 压缩行数,transform 保持行数,用错地方会白算

这是一组必须分清楚的方法。agg返回每个分组一行,transform返回和原表等长的结果,把组内统计值广播回每一行。最常见的用途是算占比:

df["channel_total"] = df.groupby("channel")["amount"].transform("sum") df["share"] = df["amount"] / df["channel_total"]

用agg加merge也能实现同样效果,但多一次连接、多一份内存,transform一步到位。transform里同样支持自定义函数,只要它返回标量或者与输入等长的序列。需要提醒的是transform对返回类型比较敏感,自定义函数返回类型不一致时新版本会给出警告甚至报错,写的时候尽量保证分支返回同一种类型。

至于apply,它最灵活(能返回任意形状),也最慢,而且返回值结构不确定。我的原则是:能用agg或transform表达的绝不用apply,实在要写复杂逻辑再用,并且明确它的返回类型。

5.3 groupby 默认丢掉 NaN 分组,这是总量对不上的头号原因

这个坑值得单独拿出来说。df.groupby("channel")["amount"].sum().sum()得到的数,经常比df["amount"].sum()小,原因就是分组键为 NaN 的那些行被默认丢弃了。从 pandas 1.1 开始提供了dropna=False参数来保留它们:

print(df.groupby("channel", dropna=False)["amount"].sum())

除了dropna,分类类型(category)列还有observed参数。早期版本默认observed=False,会把所有理论上存在的类别都列出来,包括数据里一次都没出现过的,于是你会得到一堆计数为 0 的分组,做图表时特别难看。新版本已把这个默认值改为True,如果你维护的是老代码,升级后分组结果行数突然变少,八成就是这个原因。这两个参数都属于"不出事不知道,一出事查半天"的类型,建议在写聚合代码时就把它们显式写出来。

6. 时间序列上的累计统计:rolling、expanding、ewm 的分工

前面讲的都是"把一列压成一个数",时间序列里还有一类需求是"每个点都算一个数",对应的就是窗口类函数。这三个函数经常被混用,但它们解决的问题完全不同。

6.1 rolling 的 min_periods 决定了窗口没填满时算不算

rolling(window=7)表示取当前行往前 7 行。关键参数是min_periods,默认等于window,也就是窗口必须攒满 7 个有效值才出结果,前面 6 行全是 NaN。很多人第一次用会以为代码写错了,其实只是默认值保守。

s = pd.Series([10, 12, 11, 15, 14, 13, 16, 18]) print(s.rolling(3).mean().tolist()) # 前两个是 NaN print(s.rolling(3, min_periods=1).mean().tolist()) # 从第一行就有值 print(s.rolling(3, center=True).mean().tolist()) # 窗口居中

选min_periods是个业务判断题:做质量监控时,前期样本不足就不该给结论,保持默认更负责任;做趋势展示时,前几行留白会让图表很怪,设成 1 或者 2 更合适。另外window除了整数还能传时间偏移(如"7D"、"30min"),前提是索引是DatetimeIndex且递增有序——乱序的话新版本会直接抛错,先sort_index()再滚。

closed参数管的是窗口边界包含关系,默认"right",也就是不包含当前行所在的时间点本身。在算"过去 24 小时"这种指标时,边界包不包含会带来一行数据的差异,对账的时候经常卡在这种细节上。

6.2 expanding 是"从开头累到现在",ewm 是"越近权重越大"

expanding()没有固定窗口,每个位置都是用从头到当前的所有数据计算,配合min_periods控制起算点。它适合算累计转化率、累计均值这类"随着样本增加逐渐稳定"的指标:

df["cum_cvr"] = df["is_paid"].expanding(min_periods=100).mean()

ewm()则引入指数衰减权重,span越大衰减越慢、曲线越平滑。这里有个adjust参数要注意:adjust=True(默认)用的是归一化权重,前期数值波动大但更"数学正确";adjust=False用的是递推公式,前期受初值影响明显,但计算快、且和很多流式计算框架的口径一致。两个结果在数据量大了以后会收敛到几乎一样,可如果你拿来做实时指标对比,前几十个点对不上很正常,先确认 co 是不是同一个adjust。

6.3 diff 和 pct_change:算环比时那几个必须处理的边界

diff(periods=1)算的是相邻差值,NaN 会原样传下去,所以第一个值必然是 NaN,这没什么问题。pct_change()算的是变化率,麻烦在于分母为 0 的时候,它会给你inf或者-inf,而不是 NaN。这个inf一旦流进后续的mean(),整列结果就被污染了。

chg = df["amount"].pct_change() print(chg.replace([np.inf, -np.inf], np.nan).describe())

另外,较新版本里pct_change的fill_method参数默认值已经不再自动做前向填充了(相关默认行为被标记弃用),也就是说缺失值会直接导致相邻的变化率变成 NaN。这其实是好事——前向填充会让"三天没数据"看起来像"当天没有变化",是一个更隐蔽的谎。如果你的历史代码里依赖了旧行为,升级后结果变了,先去检查这个参数。

做同比、环比这类指标,我的固定套路是先sort_values保证顺序、再diff或pct_change,最后统一把inf换成 NaN 并统计一下被换掉的比例。这个比例本身就是个很好的数据质量信号——如果 5% 的环比都算不出来,说明分母为 0 的情况太多,得回头看看源数据是不是有问题,而不是急着把异常值抹平。

最后分享一个我自己的使用习惯:任何一份要交出去的数据,我都会在最后跑一遍"交叉校验"——用两套不同路径算同一个指标。比如总量既用df["amount"].sum()算,也用groupby分组求和再sum()一遍,两个数必须严格相等。不等的话,九成是分组键里有 NaN、或者哪一列被悄悄转成了 object。这套习惯帮我抓住过好几次"看起来完全正常"的统计错误,比事后对着报表追责省事太多。

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

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

立即咨询