上一章把groupby的分组机制、遍历方式、基础聚合过了一遍重点在怎么把数据按某个键切开。真正让人头疼的活儿其实在切开之后一列数据要同时出求和、均值、最大值出来的列名还得让人一眼看懂算完的指标要保持原表行数方便拼回去做后续计算有些指标必须先在组内加权再汇总随手丢一个mean上去数就错了。这一章聊的就是Pandas 数据聚合与分组里更贴近实战的那一层agg的各种写法、命名聚合怎么用、transform和apply的分工边界、pivot_table与crosstab该在什么场景出场以及多级索引怎么收尾、性能怎么保住。适合已经会用groupby().sum()、但一遇到多函数聚合列名乱成一团或算完不知道怎么接回原表就卡住的人也适合想把分析脚本从能跑改成跑得快的人。1. 聚合结果的三种形态先想清楚要哪一种分组聚合这件事很多人卡住不是因为不会写 API而是因为没想清楚我要的输出长什么样。Pandas 在这一层的设计其实很清晰几乎所有聚合方法最终都会落到三种形态之一认清形态再选方法比背函数签名有用得多。1.1 压缩型聚合一行代表一组最典型的就是sum、mean、count、max这类一组数据压成一个数行数从几万变成几十。这类结果的行索引就是分组键列索引是你要的指标。它的价值在于看全局比如各区域的销售总额、各渠道的平均客单价。代价是丢掉了明细你没法再回到原始行级别去做逐行判断。这里有个容易忽略的点压缩型聚合的输出形状是由分组键的数量决定的。单列分组出来是单层索引两列分组出来就是 MultiIndex后面接透视、接 merge、接 Excel 导出都得为这个多出来的层级做处理。所以动手前先问自己一句这个结果最后是给人看还是要继续参与计算给人看的可以保留多级索引继续算的尽早摊平。1.2 同形型输出行数和原表一致transform系列属于这一类输入一万行输出还是一万行每行填的是它所在组的某个统计值。这个形态看着不起眼但它是组内比较的唯一正解。想做组内占比、组内标准化、用组内中位数填补缺失值、给每行打上组内排名都必须在同形输出上做。用压缩型结果去 merge 回原表当然也能实现但多了一步连接代码长、出错点多、还慢。我见过不少脚本是先agg再merge再fillna三步做完其实transform一行就够。区别不在结果对不对而在维护成本。1.3 变形型输出行列互换的二维汇总pivot_table和crosstab属于这一类把一维的分组结果摊成二维表行是维度 A列是维度 B格子里是指标值。这类输出最适合直接贴进报表或者喂给绘图库也是最容易被业务方要求再加一列合计把空的地方填 0的形态。它的底层依然是分组聚合只是多做了一步unstack理解了这层关系遇到透视表的奇怪行为就能倒推回去查。1.4 三种形态的选型对照输出形态代表方法行数变化典型用途常见坑压缩型agg/sum/mean变少汇总报表、指标计算多函数聚合列名成多级同形型transform不变组内占比、标准化、填充自定义函数返回值长度必须一致变形型pivot_table/crosstab行列重排二维报表、绘图输入小计行不是简单相加筛选型filter变少但保留原列剔除样本量过小的组条件函数返回布尔值而非表把这四种形态记牢后面所有的参数选择都有了判断依据。我自己的习惯是写聚合代码前先在纸上画一下目标表的行列结构画完基本就知道该用哪个方法了。2. agg 的写法与命名聚合把列名一起管好agg是这一层最核心的方法也是最容易写出结果对但列名没法看的地方。它的参数形态有好几种每种返回的结构都不一样混用会直接报错。2.1 单列多函数与多列多函数先选列再聚合和多列一起聚合返回结构完全不同。df.groupby(k)[v].agg([sum, mean])返回的是 DataFrame索引是分组键列名就是函数名字符串而df.groupby(k).agg([sum, mean])返回的列是两层 MultiIndex第一层是原列名第二层是函数名。后者信息更全但列名处理起来更麻烦导出 CSV 时经常变成amount_sum这种带下划线的拼接形态。多列不同函数就要传字典{amount: [sum, mean], qty: sum}。字典的键是列名值可以是单个函数名也可以是函数名列表。这里有个硬性限制需要注意字典写法只能用在 DataFrameGroupBy 上也就是必须先df.groupby(k)而不能先取列df.groupby(k)[amount]。对 SeriesGroupBy 传字典新版 Pandas 会直接抛SpecificationError: nested renamer is not supported。这个报错我见过太多人卡住因为旧教程里这种写法是能跑的。2.2 命名聚合一次把列名定死命名聚合是解决列名问题最干净的办法写法是把新列名 (源列名, 函数)作为关键字参数传给aggsummary orders.groupby(region).agg( order_cnt(order_id, count), total_qty(qty, sum), total_amount(amount, sum), max_price(price, max), )出来的表列名就是order_cnt、total_qty这几个索引是region干干净净一层不需要任何后处理。它还有两个隐性好处一是列的顺序完全由你写的顺序决定不用再reindex二是同一个源列可以被多次使用比如对amount同时取sum和max各起各的名字互不干扰。有几点必须注意。命名聚合的关键字参数名不能是 Python 关键字也不能和分组键重名否则会覆盖索引同一个agg调用里不能混用命名聚合和普通字典写法混了必报错如果确实需要动态生成比如列名来自配置表可以用pd.NamedAgg(columnamount, aggfuncsum)显式构造效果等价但更啰嗦一般只在循环里用。提示命名聚合的关键字参数允许中文标识符Python 语法层面是合法的但导出到某些下游工具可能有编码麻烦团队协作时建议统一用英文字段名展示层再改名。2.3 自定义函数的两个入口与代价agg接受的不只是函数名字符串也可以是 lambda、普通函数、甚至 numpy 的 ufunc。传自定义函数时函数收到的是每个分组的 Series如果是 SeriesGroupBy或子 DataFrame如果是 DataFrameGroupBy返回值可以是标量也可以是 Series。代价在这里字符串函数名走的是 Pandas 内部的 C 实现或者高度优化过的路径lambda 走的是 Python 逐组调用速度差一个量级很常见。数据量上了百万行、分组数上万这个差距会从多等两秒变成多等两分钟。所以我的原则是能用字符串就用字符串实在要自定义就先看能不能拆成几个内置函数再组合。还有一个细节lambda 聚合出来的列名就是lambda多个 lambda 会得到一堆同名同姓的列后续根本没法引用。这时候要么改用命名聚合要么在agg之后手动赋columns。分位数计算是典型场景直接写成命名聚合更省事stats orders.groupby(region).agg( p50(amount, lambda s: s.quantile(0.5)), p90(amount, lambda s: s.quantile(0.9)), )2.4 一份订单表跑通全部写法拿一份虚构的订单明细做演示字段是区域、渠道、品类、数量、单价金额由数量和单价相乘得到import pandas as pd orders pd.DataFrame({ order_id: range(1, 13), region: [东区,东区,南区,南区,西区,西区] * 2, channel: [线上,线下] * 6, category: [标准,礼盒] * 6, qty: [3, 2, 5, 1, 4, 6, 2, 3, 7, 1, 4, 5], price: [99.0, 120.0, 88.0, 150.0, 66.0, 45.0, 99.0, 88.0, 72.0, 150.0, 60.0, 45.0], }) orders[amount] orders[qty] * orders[price]第一层汇总用命名聚合一次到位然后补一个加权均价。这里就是最容易出错的地方均价必须用总金额除以总数量不能对单价列直接取mean。因为每单数量不同直接平均单价等于给每个订单相同的权重数量大的订单被稀释了。这个错误在报表里非常常见而且不容易被发现因为两种算法出来的数都在合理区间内。summary orders.groupby(region).agg( order_cnt(order_id, count), total_qty(qty, sum), total_amount(amount, sum), ) summary[avg_price] summary[total_amount] / summary[total_qty]同理如果要对两列做不同函数、并且还想保留品类维度就换成多列分组加命名聚合索引变成两层再用reset_index摊平即可。3. transform 与 apply形状一致和自由发挥的分界线这两个方法经常被混用因为它们看起来都能对每个组做点事。区别其实很硬transform对输出形状有强约束apply没有。3.1 transform 的硬约束与它的价值transform要求每组返回的结果能对齐回原始行要么是一个标量会被广播到该组所有行要么是与该组等长的序列。返回长度对不上就直接报错。这个约束看着麻烦实际上正是它的价值所在结果一定和原表同长同序可以直接赋值成新列不需要 merge、不需要考虑索引对不对齐。transform传字符串函数名时走的是和agg同样的优化路径速度很快orders.groupby(region)[amount].transform(sum)。传 lambda 时同样是逐组 Python 调用慢但灵活。有个细节值得记住transform里的std默认是样本标准差ddof1如果某个组只有一个元素结果是NaN而不是 0。做标准化时这一点会让结果凭空多出空值很多人在这一步困惑。3.2 三个高频场景组内占比、组内填充、组内排名组内占比是最常写的把每行金额除以它所在区域的总金额。用transform(sum)一行搞定分母和原表逐行对齐除法直接成立。orders[region_total] orders.groupby(region)[amount].transform(sum) orders[share_in_region] orders[amount] / orders[region_total]组内填充是另一个高频需求用各区域的中位数填补该区域的缺失单价。必须先transform(median)得到同形结果再fillna不能直接groupby().median()然后fillna因为后者是压缩型结果长度不对。组内排名用rank更直接它本身就是同形方法orders[rank_in_region] orders.groupby(region)[amount].rank( ascendingFalse, methoddense )method参数值得说一句average是默认值并列时给平均名次会出现 1.5 这种小数dense是密集排名并列后名次不跳号做区域前十这类榜单时更符合直觉min是并列都取最小名次后面跳号。选哪个取决于业务口径不是技术问题但选错会导致榜单条数和预期不符。3.3 apply 的弹性与它的代价apply几乎什么都能做可以返回标量、Series、DataFramePandas 会根据返回类型自动拼装结果。这种弹性在处理一次算出多个相关指标时特别顺手比如算完总金额、订单数、还要顺手算一个复购率。代价有两个。第一是慢apply是纯 Python 循环没有任何向量化优化分组数上万时性能会明显掉队。第二是返回结构不可预测返回 Series 会变成多级索引的 DataFrame返回 DataFrame 会变成三层索引很多时候你得先打印一下shape和columns才知道自己拿到了什么。我的经验是能用agg表达的就别用apply能用transform表达的就别用applyapply留给逻辑确实复杂、必须看整个组才能决定的情况。真要用也尽量在函数里返回 Series 而不是 DataFrame返回值结构至少可控一点。3.4 filter按组筛选而不是按行筛选filter常被忽略但它解决的是一个很实际的问题剔除样本量太小的分组。注意它和布尔索引的区别——布尔索引筛的是行filter筛的是整组返回的是原始表里符合条件的那些组的全部行列结构完全不变。big_regions orders.groupby(region).filter(lambda g: g[qty].sum() 10)传进去的函数收到的是子 DataFrame必须返回布尔值。返回True的组整组保留。这个用法在做分组对比分析前特别有用样本太小的组留在里面会拉低整体结论的可信度。缺点是它同样走 Python 循环分组数特别多时不如先算组统计量再isin筛选来得快。4. 透视表与交叉表二维汇总怎么不写错到了要出二维报表的时候pivot_table和crosstab就该上场了。这两个方法底层都是分组聚合加unstack理解这层关系参数就好记了。4.1 pivot_table 参数的逐个拆解核心参数就六个values是要汇总的指标列index是行维度columns是列维度aggfunc是聚合函数fill_value是空格填充值margins控制是否加合计。默认aggfunc是mean这一点坑过很多人——不写聚合函数出来的是平均值而不是你想要的合计。pt pd.pivot_table( orders, valuesamount, indexregion, columnschannel, aggfuncsum, fill_value0, marginsTrue, margins_name合计, )fill_value0建议默认加上。因为某个区域可能压根没有线下渠道的数据透视出来是NaN导出到表格里就是刺眼的空白业务方第一反应是数据丢了。填 0 之后语义变成该组合没有发生更准确。aggfunc也支持传列表出来是多级列索引可以做金额求和 订单计数双指标透视但列名处理会麻烦一些摊平的时候要小心层级顺序。4.2 margins 小计行的三个坑第一个坑margins的合计行不是把上面几行加起来而是对原始数据重新聚一次。对sum和count而言两种算法结果一样但对mean、median、分位数这类非可加指标合计行是按原始明细重算的会和你手动把各行相加的结果不一样。这不是 bug是设计如此但必须知道否则对不上数时会怀疑人生。第二个坑margins_name默认是All如果中文报表里不接受这个值一定要显式改否则下游脚本按合计取行取不到。第三个坑加了marginsTrue之后最后一行一列的索引值不在原来的类别里后续如果要做loc切片或者按行画图得先把合计行切出去单独处理不要混在明细里一起算增长率。4.3 crosstab 做频次与结构占比crosstab更偏计数场景最常见的是做交叉频次表和结构占比。只传两个 Series 就是频次统计加values和aggfunc就能做数值汇总加normalize直接出占比pd.crosstab(orders[region], orders[channel], marginsTrue) pd.crosstab(orders[region], orders[channel], normalizeindex)normalize的取值决定分母index是每行合计为 1看的是每个区域内部渠道结构columns是每列合计为 1看的是每个渠道的区域分布True或all是全体合计为 1。选错分母结论方向完全相反这是写交叉表最容易翻车的地方。我一般会在列名或者图标题里明确写上行占比防止读者误读。4.4 多级索引列的摊平透视之后列是 MultiIndex直接存 CSV 会变成两层表头很多工具读不了。摊平有两种写法一种是把列名元组用下划线连起来另一种是直接取第二层当第一层只有values一个值时。flat pt.copy() flat.columns [ _.join([str(x) for x in c]) if isinstance(c, tuple) else str(c) for c in flat.columns ] flat flat.reset_index()用列表推导处理的时候记得过滤空字符串因为indexFalse的透视表列名元组里可能有一层是空串拼出来会有多余的下划线。这个小细节不影响功能但影响下游按列名匹配的稳定性值得顺手处理掉。5. 分组键与索引的处理细节聚合结果数值对但用不了的情况十有八九是索引没处理好。这部分没有技术难度但特别费时间。5.1 多列分组后的 MultiIndex 结构groupby([region, channel])出来的索引是两层顺序就是你传列表的顺序。这个顺序会一直带到后面的unstack、reset_index、merge里所以传参顺序不是随便写的得按你最终想要的层级来排。多级索引取值有两种方式loc用元组xs用level参数。前者直观一些后者在层级较深时更好用。我个人的习惯是尽早reset_index摊成普通列因为多级索引在merge、concat、条件筛选中都容易出意外尤其是两个都是多级索引的表做连接对齐规则会变得很难预期。5.2 as_indexFalse 与 reset_index 的取舍groupby(..., as_indexFalse)能让聚合结果直接就是普通列省掉一次reset_index。但要注意它对命名聚合之外的写法也生效字典聚合出来的多级列名依然存在只是索引变了。也就是说as_indexFalse解决的是行索引问题不解决列层级问题。reset_index的优势是可以控制dropTrue丢弃旧索引也可以指定names给新列改名。什么时候用哪个如果这条链路上后续不做按组索引取值直接as_indexFalse更简洁如果中途需要loc定位某个组就先留着索引最后再reset_index。5.3 三个默认值dropna、observed、sortdropna控制分组键里的空值要不要单独成组。较新的 Pandas 版本里默认是True也就是空值所在的行会被整行丢弃不参与任何聚合。这在做数据质量核查时非常危险——你以为统计了全部订单其实悄悄少了一批。做完整性校验时建议显式写dropnaFalse让空值变成一个独立的组一眼就能看到有多少行缺键。observed只对 Categorical 类型的分组键生效。默认False时即使某个类别在当前数据里一次都没出现也会在结果里占一行值为 0 或 NaN。新版 Pandas 对这个默认值有调整并给出过警告稳妥做法是不管什么版本都显式写observedTrue除非你确实需要补齐所有类别。sort默认True会对分组键排序。排序在分组数很大时有明显开销如果你后续不需要按顺序遍历写sortFalse能省下一点时间。这个优化单独看收益不大但在循环里跑几十次就有感觉了。5.4 聚合结果回接原表的两种路子回到明细表有两种方式transform和merge。判断标准很简单能一次transform出来的就别merge。需要多列、多函数的复杂结果走merge更清晰但要保证连接键的列名和类型一致尤其是分组键里有整数和字符串混用的情况merge会静默匹配不上然后给你一堆空值。merge的时候我会习惯性加上validatemany_to_one让 Pandas 帮我检查右表是不是真的每个键只有一行。多加这一行代码能提前拦住维度表有重复导致结果翻倍这类最难查的问题。6. 性能与排查跑得动、跑得对代码写对只是及格线跑得动、跑得稳才算过关。这一层是分组聚合性能问题最集中的地方。6.1 几条立竿见影的提速路径第一条先选列再分组。df.groupby(k)[a].sum()比df.groupby(k).sum()快不少因为后者会尝试对所有数值列做聚合白算了一堆用不上的。列数越多差距越明显。第二条能用内置函数就用内置函数。字符串函数名走的是优化路径lambda 是 Python 逐组调用。一个简单的quantile(0.9)用字符串传不了但可以用agg里内置的median先顶上实在不行再自定义。第三条把分组键转成 Categorical。低基数的字符串列比如只有几个取值的渠道、状态转成 category 类型后分组时的哈希计算开销会明显下降内存也省。缺点是转换本身有成本只在反复分组同一列时才划算。第四条避免apply返回 DataFrame。返回 DataFrame 时 Pandas 要额外做一次 reshape开销远大于返回 Series。如果确实要返回多个值返回 Series 是更优解。第五条先过滤再分组。只在部分数据上做聚合时先用布尔索引裁掉不需要的行再groupby比聚合完再筛快得多。这个顺序很容易写反尤其在链式调用里。6.2 常见报错与现象速查表报错或现象常见原因处理方式SpecificationError: nested renamer is not supported对 SeriesGroupBy 传了字典或字典值里嵌了元组改成命名聚合或先df.groupby(k)再传字典ValueError: cannot insert X, already existsreset_index时新列名和已有列重名先drop原列或用rename改名KeyError: xxx聚合后列名是多级索引按单层名取不到先摊平列名再取FutureWarning: observedFalse is deprecatedCategorical 分组键未显式指定显式写observedTrue结果行数比预期少分组键有空值被dropna丢弃显式dropnaFalse检查缺键比例标准化后出现NaN单元素组的std为NaN用fillna(0)兜底或先过滤小组ValueError: Function does not reducetransform里传了返回标量以外形状的函数改用agg或调整函数返回等长序列结果数值翻倍merge时右表键重复加validatemany_to_one提前拦住6.3 我踩过的几个坑第一个坑是加权均价。前面提过一次值得再说一遍因为它在报表里极难自查。只要指标涉及比率或单价先想清楚分子分母的聚合顺序能拆成sum相除的就别用mean。这个错误不会报错只会给你一个看起来合理的错数。第二个坑是margins合计行参与后续计算。加了合计行之后表的最后一行不是真实分组如果直接拿去做同比环比或者排序会把合计当成一个区域排进去。处理办法是透视完立刻把合计行iloc[:-1]切出来单独放或者干脆先不加margins在展示层再补。第三个坑是transform和索引乱序。transform的结果是按位置对齐回原表的理论上安全但如果中途做过sort_values又做过reset_index(dropTrue)某些旧版本的链式调用里出现过对齐异常的边缘情况。我的保守做法是transform的结果赋值成新列时尽量在同一个表达式里完成不要中间插别的操作。第四个坑是聚合后的空组。observedFalse加 Categorical 分组键会把没数据的类别也列出来值为 0如果你用 0 去做除法算占比会得到一堆inf。这类结果不会报错但画图时坐标轴会直接崩掉。看到图不对劲先查一下分组结果里有没有全是 0 的行。我个人的体会是分组聚合这一层真正的门槛不在 API 数量而在先想清楚要什么形状、再决定用哪个方法这个习惯。写之前花三十秒在纸上画出目标表的行列比事后调半天列名划算得多。真遇到结果对不上的情况第一件事永远是打印shape和columns绝大多数问题在这一步就暴露了剩下的才是逻辑问题。
阅读完成 · 觉得有帮助?