5-2 聚合:诊断指标的三种输出形态 本节摘要:同样一次分组,agg把每组压成一个数、transform保持原表行数给每行标注组内信息、filter按组级指标整组放行或拦截。本节把三种输出形态一次讲透,并用命名聚合产出可直接进报告的列名。 上一节分组机制建好了,本节讲应用段:会诊报告要的不是"每组的和",而是"每组的和、均值、笔数、与组均值的偏离"——后一种需求就得换输出形态。 agg:一组长出多个诊断指标 列表与字典的 agg 会产生多层列名,报告里不好引用。命名聚合(pd.NamedAgg)一步到位: transform:保长输出,给每行标注组内信息 需求:找出"高于所在门店平均单"的交易。
本节摘要:同样一次分组,agg把每组压成一个数、transform保持原表行数给每行标注组内信息、filter按组级指标整组放行或拦截。本节把三种输出形态一次讲透,并用命名聚合产出可直接进报告的列名。
上一节分组机制建好了,本节讲应用段:会诊报告要的不是"每组的和",而是"每组的和、均值、笔数、与组均值的偏离"——后一种需求就得换输出形态。
import pandas as pd df = pd.DataFrame({ '门店': ['A', 'A', 'B', 'B', 'C'], '金额': [1280, 960, 1732, 880, 2100], }) # 一个键一个指标:单函数直接写 print(df.groupby('门店')['金额'].mean().round(1)) # 门店 # A 1120.0 # B 1306.0 # C 2100.0 # 一组多指标:agg接函数列表 print(df.groupby('门店')['金额'].agg(['sum', 'mean', 'count'])) # sum mean count # 门店 # A 2240 1120.0 2 # B 2612 1306.0 2 # C 2100 2100.0 1 # 不同列要不同指标:字典写法 print(df.groupby('门店').agg({'金额': ['sum', 'max']})) # 金额 # sum max # 门店 # A 2240 1280 # B 2612 1732 # C 2100 2100
列表与字典的 agg 会产生多层列名,报告里不好引用。命名聚合(pd.NamedAgg)一步到位:
report = df.groupby('门店').agg( 总金额=('金额', 'sum'), 均单=('金额', 'mean'), 笔数=('金额', 'count'), ) print(report) # 总金额 均单 笔数 # 门店 # A 2240 1120.0 2 # B 2612 1306.0 2 # C 2100 2100.0 1 print(report['均单'].idxmax()) # 'C' —— 但C只有一笔,下文filter会收拾它
需求:找出"高于所在门店平均单"的交易。错误思路是拿全局均值比(A店的1280会被B店的均值冤枉),正确思路是组内比较:
# transform返回与原表等长的序列:每行得到它所在组的均值 df['店均单'] = df.groupby('门店')['金额'].transform('mean') print(df) # 门店 金额 店均单 # 0 A 1280 1120.0 # 1 A 960 1120.0 # 2 B 1732 1306.0 # 3 B 880 1306.0 # 4 C 2100 2100.0 # 组内比较:一行布尔筛选搞定 print(df[df['金额'] > df['店均单']]) # 门店 金额 店均单 # 0 A 1280 1120.0 # 2 B 1732 1306.0
💡 关键直觉:agg越分越少、transform保持行数。判断用哪个只需要问一句:结果要贴回原来的每一行吗?要,就transform。
# 样本太少的组不可信:只有一笔的C店不该进报告 big_enough = df.groupby('门店').filter(lambda g: len(g) >= 2) print(big_enough['门店'].unique()) # ['A' 'B'] C整组被拦下 # 组均值不达标的整组淘汰 print(df.groupby('门店').filter(lambda g: g['金额'].mean() > 1100)['门店'].unique()) # ['A' 'B']
filter是"先汇总判断、再按行返回"的打包写法。手动实现要先算组指标、再isin、再布尔筛,四步并一步,且语义读起来就是一句话:留下满足条件的组。
| 器械 | 输出形态 | 典型场景 | 结果行数 |
|---|---|---|---|
| agg | 每组一行 | 汇总报告 | 组数 |
| transform | 与原表等长 | 组内标注、组内比较 | 原行数 |
| filter | 原表的子集 | 按组级标准筛组 | 通过组的行数 |

内置指标覆盖不了的需求(比如"每组前两大客户的合计")用自定义函数进agg。写法上只要记住契约:拿到一张子表,返回一个标量:
def top2_share(g): """组内前两大单占组内总额的比例:客户集中度指标""" top2 = g.nlargest(2).sum() return round(top2 / g.sum(), 3) print(df.groupby('门店')['金额'].agg(top2_share)) # 门店 # A 0.994 # 两单几乎就是全部,集中度极高 # B 0.928 # C 1.000 # 只有一单,比例自然封顶
注意C店的1.0——单笔组里集中度指标退化,这类"指标在小样本上的假信号"正是上一节filter要把小样本组拦出去的原因。自定义聚合虽好,代价是失去向量化加速,十万行以上明显变慢,能用内置就别自定义。
把本节三件器械拼成一张管理层口径的指标卡:
card = df.groupby('门店').agg( 总额=('金额', 'sum'), 均单=('金额', 'mean'), 笔数=('金额', 'count'), ).assign( 单笔贡献=lambda x: (x['总额'] / x['笔数']).round(1), 总额占比=lambda x: (x['总额'] / x['总额'].sum() * 100).round(1), ) print(card) # 总额 均单 笔数 单笔贡献 总额占比 # 门店 # A 2240 1120.0 2 1120.0 36.1 # B 2612 1306.0 2 1306.0 42.1 # C 2100 2100.0 1 2100.0 33.8
命名聚合出基础列、assign补派生列,一气呵成。这张卡就是下一节要排版的原始素材。
下一节把会诊结果排版成正式报告:透视表与交叉表。