073、Pandas 分组与聚合:groupby、pivot_table、transform 与窗口函数
073、Pandas 分组与聚合groupby、pivot_table、transform 与窗口函数上周帮一个数据分析团队排查线上报表异常发现他们用 groupby 之后直接取 .mean()结果某个分组的均值被 NaN 污染了但没人注意到。更隐蔽的是他们想计算“每个用户当月消费占比”用 groupby 加 transform 写错了轴导致所有占比加起来不等于 1。这种坑我踩过不止一次今天把 Pandas 分组聚合的四个核心工具掰开揉碎讲清楚。从 groupby 的“假分组”说起很多人以为 groupby 就是“按某列分组然后算个值”但它的底层逻辑是Split-Apply-Combine三步走。先看一个典型翻车现场importpandasaspdimportnumpyasnp dfpd.DataFrame({user:[A,A,B,B,C],amount:[100,200,150,np.nan,300],date:[2024-01,2024-02,2024-01,2024-02,2024-01]})# 这里踩过坑直接 groupby(user).mean() 会忽略 NaNresultdf.groupby(user)[amount].mean()print(result)# 输出# user# A 150.0# B 150.0 # 注意B组只有一个有效值150另一个NaN被跳过了# C 300.0如果你期望 B 组的均值是 (150 NaN)/2 75那你就错了。Pandas 默认 skipnaTrueNaN 被直接丢弃。更隐蔽的是如果你用 .agg() 传多个函数不同函数对 NaN 的处理可能不一致。别这样写依赖默认行为而不显式指定 skipna。正确的做法是# 显式控制NaN处理resultdf.groupby(user)[amount].agg([mean,sum,count])# 或者用 np.nanmean 强制保留NaNimportnumpyasnp resultdf.groupby(user)[amount].agg(lambdax:np.nanmean(x)ifx.notna().any()elsenp.nan)groupby 的另一个常见误区是多级索引的访问。当你 groupby 两个列时返回的是 MultiIndex很多人直接 .loc[‘A’] 取不到数据因为索引层级变了。用 .xs() 或者 reset_index() 更安全。pivot_tableExcel 透视表的 Pandas 版本pivot_table 本质上是 groupby 的“表格化”变体但它能自动处理多级行索引和列索引。我见过最离谱的用法是有人用 groupby 加 unstack 手动拼透视表代码写了三十行其实一行 pivot_table 就能搞定。# 模拟销售数据salespd.DataFrame({region:[North,North,South,South,East],product:[A,B,A,B,A],sales:[100,200,150,250,300],quarter:[Q1,Q1,Q2,Q2,Q1]})# 这里踩过坑pivot_table 默认 aggfuncmean不是 sumptsales.pivot_table(valuessales,indexregion,columnsproduct,aggfuncsum,fill_value0# 别忘记这个参数否则NaN会很难看)print(pt)# product A B# region# East 300 0# North 100 200# South 150 250别这样写用 pivot_table 时不指定 fill_value然后后面手动 fillna(0)。不仅多一步而且如果数据中有真实的 NaN 和缺失值混在一起你根本分不清。pivot_table 还有一个隐藏参数 marginsTrue可以自动添加行/列汇总。但注意margins 的汇总行默认也是用 aggfunc 计算的如果你 aggfunc‘mean’那 margins 行就是所有组的均值不是总和。很多人在这里翻车以为 margins 是 sum。transform分组计算的“广播”利器transform 是我认为 Pandas 里最优雅但最容易被误解的函数。它的核心特点是返回与原始 DataFrame 相同形状的对象。这意味着你可以用 transform 计算分组内的统计量然后直接加到原数据上做比较。# 计算每个用户当月消费占比dfpd.DataFrame({user:[A,A,B,B,C],amount:[100,200,150,250,300],month:[Jan,Feb,Jan,Feb,Jan]})# 这里踩过坑直接用 groupby 加 transform 但忘记指定列df[total_by_user]df.groupby(user)[amount].transform(sum)df[pct]df[amount]/df[total_by_user]print(df)# user amount month total_by_user pct# 0 A 100 Jan 300 0.333333# 1 A 200 Feb 300 0.666667# 2 B 150 Jan 400 0.375000# 3 B 250 Feb 400 0.625000# 4 C 300 Jan 300 1.000000别这样写先 groupby 算 sum然后用 merge 或 map 拼回去。transform 一步到位而且不会改变索引顺序避免了 merge 时常见的索引错位问题。transform 的另一个高级用法是自定义函数。比如你想对每个分组内的数据做标准化减去均值除以标准差defz_score(x):return(x-x.mean())/x.std()df[amount_z]df.groupby(user)[amount].transform(z_score)注意transform 的自定义函数必须返回与输入相同长度的序列或者一个标量会被广播到整个分组。如果你返回了不同长度Pandas 会报错而且错误信息很隐晦只说“Transform function returned an inconsistent number of samples”。窗口函数rolling、expanding 与 ewm窗口函数是分组聚合的“时间维度”扩展。很多人以为窗口函数只能用在时间序列上其实它可以用在任何有序数据上。# 计算每个用户过去2笔订单的移动平均dfpd.DataFrame({user:[A,A,A,B,B],order:[1,2,3,1,2],amount:[100,200,150,300,250]})# 这里踩过坑rolling 默认是向前窗口不是向后df[rolling_avg]df.groupby(user)[amount].rolling(2,min_periods1).mean().reset_index(level0,dropTrue)print(df)# user order amount rolling_avg# 0 A 1 100 100.0# 1 A 2 200 150.0# 2 A 3 150 175.0# 3 B 1 300 300.0# 4 B 2 250 275.0别这样写忘记 reset_index(level0, dropTrue)。groupby 加 rolling 返回的是 MultiIndex如果不重置索引直接赋值给原 DataFrame 会导致索引错位数据全乱。expanding 窗口函数比 rolling 更直观它从第一个元素开始累积计算# 累计总和df[cumsum]df.groupby(user)[amount].expanding().sum().reset_index(level0,dropTrue)ewm指数加权移动平均适合对近期数据赋予更高权重df[ewm_avg]df.groupby(user)[amount].ewm(alpha0.5).mean().reset_index(level0,dropTrue)alpha 参数控制衰减速度alpha 越大近期数据权重越高。很多人搞不清 alpha 和 span 的关系简单记alpha 2/(span1)span 是“有效窗口大小”。组合拳groupby transform 窗口函数实际业务中这三个工具经常组合使用。比如计算“每个用户当月消费占其历史总消费的百分比”# 先按用户分组用 expanding 计算累计消费df[cumsum_by_user]df.groupby(user)[amount].expanding().sum().reset_index(level0,dropTrue)# 当月消费占比df[pct_of_history]df[amount]/df[cumsum_by_user]再比如计算“每个用户当月消费与过去3个月均值的偏差”df[rolling_3m_avg]df.groupby(user)[amount].rolling(3,min_periods1).mean().reset_index(level0,dropTrue)df[deviation]df[amount]-df[rolling_3m_avg]这里踩过坑如果数据不是按时间排序的rolling 的结果毫无意义。一定要先 sort_values([‘user’, ‘date’])再分组做窗口计算。性能陷阱与调试技巧groupby 的 apply 很慢能用 transform 或 agg 解决的别用 apply。apply 是逐组调用 Python 函数性能差一个数量级。pivot_table 的 fill_value 参数如果你用 fill_value0但数据中确实有 0 值你会分不清是缺失还是真实 0。建议用 fill_valuenp.nan然后单独处理。窗口函数的 min_periods默认 min_periods 等于窗口大小这意味着前几个窗口会返回 NaN。如果你不想看到 NaN设置 min_periods1。调试技巧当你怀疑 groupby 结果不对时先 .apply(list) 看看每个分组里到底有哪些数据。很多人以为分组是按某个列的值但实际上 groupby 是按索引的哈希值如果索引有重复结果会出乎意料。个人经验永远不要相信默认参数groupby 的 as_index、sortpivot_table 的 aggfunc、fill_value窗口函数的 min_periods每个默认值都可能让你踩坑。写代码时显式指定所有关键参数既是给未来的自己看也是给同事看。先小数据验证再上全量分组聚合的 bug 往往在边界情况比如某个分组只有一条数据、全是 NaN、或者索引不连续才会暴露。先用 .head(100) 跑一遍确认逻辑正确再跑全量。学会用 .pipe() 串联操作当你有一连串的 groupby、transform、窗口计算时用 .pipe() 把每一步封装成函数代码可读性会好很多也方便单元测试。分组聚合是 Pandas 的精髓也是最容易出 bug 的地方。记住分组不是目的洞察才是。