分组聚合:Excel 数据透视表的代码版)
写在前面上一篇我们把脏数据收拾干净了类型统一、重复行删掉、异常值也标了出来。数据干净之后下一步就是回答老板最关心的那句——“按城市、按产品分别是多少”这就是分组聚合Excel 里叫数据透视表。用鼠标拖字段当然能做但每周都要重做一遍就很烦。这篇我们把数据透视表的每一个动作翻译成 groupby 的代码单列分组、多列分组、一次算多个统计量、给结果列起中文名、算出每组里最大的那一单、以及组内占比。学完这一篇社团统计报名人数、课程设计算平均分、工作里做月度报表都是同一套写法。先保证示例数据在手边第 1 篇的 mkdata.pyimport osimport mkdata # 第 1 篇里的 mkdata.pyif not os.path.exists(“data/sales.csv”):mkdata.main() # 没生成过就先跑一次print(“示例数据就绪”, os.path.exists(“data/sales.csv”))这段会检查第 1 篇生成的 data/ 目录在不在不在就自动补一次已经有数据时只会打印一行 示例数据就绪 True不影响后面的结果。一、groupby 的世界观拆分—计算—合并按「城市 × 产品」两个维度分组的销售额就是 groupby 的直接成果——Excel 数据透视表也是同一张图。先看这张销售明细表。它一共 16 行城市列里有空值金额列里也有空值这正是真实报表的样子import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)print(df.head(4).to_string(indexFalse))print(“形状”, df.shape)订单日期 城市 产品 数量 单价 销售员 金额2024-01-05 杭州 A-笔记本 3 49.9 小张 149.72024-01-06 上海 B-鼠标 5 29.9 小李 149.52024-01-06 上海 B-鼠标 5 29.9 小李 149.52024-01-09 北京 C-键盘 2 89.0 小王 178.0形状 (16, 7)groupby(“城市”) 做的事可以拆成三步拆分split把 16 行按城市切成 杭州、上海、北京、广州 等几堆计算apply每一堆各自算你指定的统计量合并combine把每堆的结果拼回一张表城市变成索引。所以 df.groupby(“城市”) 单独写在代码里是没有结果的它只是个待计算的中间对象必须再跟一个聚合函数sum / mean / size ……才会出数。二、单列分组size、count、sum、mean 到底差在哪import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)print(“— 订单数size”)print(df.groupby(“城市”, dropnaFalse).size())print(“— 有金额的订单数count”)print(df.groupby(“城市”, dropnaFalse)[“金额”].count())— 订单数size城市上海 4北京 3广州 3杭州 5NaN 1dtype: int64— 有金额的订单数count城市上海 4北京 2广州 2杭州 5NaN 1Name: 金额, dtype: int64这里有两个新手最容易混淆的点size 数的是行数count 数的是非空值个数。上海 4 单但金额有值的只有 4 个上海这组刚好没有缺失广州 3 单却只有 2 个有金额说明广州那组有一行金额是空的。做订单量报表用 size做有效金额笔数用 count。dropnaFalse 一定要加。城市列为空的那一行默认会被 groupby 悄悄丢掉dropnaTrue 是默认值报表里就少了 149.7 元。加了 dropnaFalse空分组会以 NaN 作为组名保留下来方便你回头去补数据。接着把常用的统计量一次看全import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)print(df.groupby(“城市”, dropnaFalse)[“金额”].agg([“count”, “sum”, “mean”, “median”, “max”, “min”]).round(2).to_string())count sum mean median max min城市上海 4 1545.0 386.25 386.25 623.0 149.5北京 2 227.9 113.95 113.95 178.0 49.9广州 2 535.4 267.70 267.70 356.0 179.4杭州 5 2805.1 561.02 149.70 2995.0 -599.0NaN 1 149.7 149.70 149.70 149.7 149.7注意杭州的 min 是 -599.0、max 是 2995.0——金额列里既有一条录入错误的负数也有一条漏算了单价的异常大值。这就是为什么上一篇要先清洗聚合函数不会帮你判断数据对不对它只会老实求和。平均值被这条负数拉低中位数 149.7 反而更能代表杭州的典型客单价。三、多列分组一张二维表一个 groupby agg 同时算出订单数、销售额、客单价三个指标不必写三遍分组代码。只按城市分组太粗通常还要看哪个城市卖哪个产品好。groupby 传一个列表就行import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)result (df.groupby([“城市”, “产品”], dropnaFalse).agg(销售额(“金额”, “sum”), 有效订单数(“金额”, “count”)).reset_index().sort_values(“销售额”, ascendingFalse))print(result.to_string(indexFalse))城市 产品 销售额 有效订单数杭州 D-显示器 2396.0 2上海 C-键盘 1246.0 2广州 C-键盘 356.0 1杭州 A-笔记本 349.3 2上海 B-鼠标 299.0 2广州 B-鼠标 179.4 1北京 C-键盘 178.0 1NaN A-笔记本 149.7 1杭州 B-鼠标 59.8 1北京 A-笔记本 49.9 1北京 D-显示器 0.0 0三件事值得说明多列分组的结果是 MultiIndex多层索引正常显示时会分两行列头。reset_index() 把城市和产品还原成普通列变成一张能直接导出 Excel 的平表——报表交付前基本都要这一步。agg(销售额(“金额”, “sum”)) 是命名聚合。等号左边是新列名可以写中文右边是源列名, 函数名的元组。这是 pandas 里最好用的聚合写法不用改列名、不用处理多层列头。老写法 agg({“金额”: “sum”}) 用的是字典语义是对金额列做 sum但列名没法自定义多函数时还会产生两层列头。两种都能用新代码建议直接上命名聚合。北京 D-显示器 的销售额是 0.0、有效订单数 0因为那一行金额是空的。sum 把全空组算成 0count 算成 0而 size 会告诉你这里确实有 1 行数据——三个函数放在一起看才不会误判。四、取每组最大/最小的那一行透视表只能给你一个数字但业务上更常问的是每个城市卖得最贵的那一单是谁买的。这时要用 idxmax 拿到最大值所在的行标签再用 loc 取回整行import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)idx df.groupby(“城市”, dropnaFalse)[“金额”].idxmax()print(df.loc[idx, [“订单日期”, “城市”, “产品”, “数量”, “金额”]].to_string(indexFalse))订单日期 城市 产品 数量 金额2024-03-15 上海 C-键盘 7 623.02024-01-09 北京 C-键盘 2 178.02024-05-06 广州 C-键盘 4 356.02024-05-20 杭州 D-显示器 5 2995.02024-04-18 NaN A-笔记本 3 149.7idxmax 返回的是原表的行号df.loc[行号列表] 就能把这些行整行捞出来——这是分组后取 Top1最省事的写法。如果不想记 idxmax第二种写法是先排序再去重import pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)top1 (df.sort_values(“金额”, ascendingFalse).drop_duplicates(subset[“城市”], keep“first”).sort_values(“城市”))print(top1[[“城市”, “产品”, “金额”]].to_string(indexFalse))城市 产品 金额上海 C-键盘 623.0北京 C-键盘 178.0广州 C-键盘 356.0杭州 D-显示器 2995.0NaN A-笔记本 149.7两种写法结果一致区别在drop_duplicates 依赖排序遇到金额相同的两行时保留的是排序后靠前的那一行结果不完全确定idxmax 遇到并列最大值会报 ValueError逼你明确处理并列情况。数据量大时优先 idxmax它不用把整张表排序要取每组前 3 名这种需求则用 df.sort_values(…).groupby(“城市”).head(3)。五、transform把组内占比贴回每一行agg 的返回结果是每组一行行数和原表对不上没法直接加回原表。想要这一单占它所在城市总额的百分之几要用 transformimport pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)df[“城市销售额”] df.groupby(“城市”, dropnaFalse)[“金额”].transform(“sum”)df[“占城市比重%”] (df[“金额”] / df[“城市销售额”] * 100).round(1)print(df[[“城市”, “产品”, “金额”, “城市销售额”, “占城市比重%”]].to_string(indexFalse))城市 产品 金额 城市销售额 占城市比重%杭州 A-笔记本 149.7 2805.1 5.3上海 B-鼠标 149.5 1545.0 9.7上海 B-鼠标 149.5 1545.0 9.7北京 C-键盘 178.0 227.9 78.1杭州 A-笔记本 199.6 2805.1 7.1广州 B-鼠标 NaN 535.4 NaN广州 B-鼠标 179.4 535.4 33.5北京 A-笔记本 49.9 227.9 21.9上海 C-键盘 623.0 1545.0 40.3上海 C-键盘 623.0 1545.0 40.3杭州 B-鼠标 59.8 2805.1 2.1NaN A-笔记本 149.7 149.7 100.0北京 D-显示器 NaN 227.9 NaN广州 C-键盘 356.0 535.4 66.5杭州 D-显示器 -599.0 2805.1 -21.4杭州 D-显示器 2995.0 2805.1 106.8transform 和 agg 的唯一区别就是返回长度agg 给你每组一个值transform 给每一行都填上它所属组的那个值。所以 df[“城市销售额”] 的长度和 df 完全一致可以直接做除法不会出现赋值后一堆 NaN的经典事故。输出里杭州 D-显示器 那两条特别有教学意义单价 599 的那条占 106.8%被误录成 -599 的那条是 -21.4%两条加起来正好 85% 左右——占比超过 100% 或为负数就是数据有问题的信号。六、pivot_table和 groupby 是一回事如果 Excel 的透视表用惯了可以直接用 pd.pivot_table它内部就是 groupby unstackimport pandas as pddf pd.read_csv(“data/sales.csv”, encoding“utf-8-sig”)print(“— pivot_table 版本”)print(pd.pivot_table(df, index“城市”, columns“产品”, values“金额”, aggfunc“sum”, dropnaFalse).round(1).to_string())print(“— groupby unstack 版本”)print(df.groupby([“城市”, “产品”], dropnaFalse)[“金额”].sum().unstack(fill_value0).to_string())— pivot_table 版本产品 A-笔记本 B-鼠标 C-键盘 D-显示器城市上海 NaN 299.0 1246.0 NaN北京 49.9 NaN 178.0 0.0广州 NaN 179.4 356.0 NaN杭州 349.3 59.8 NaN 2396.0NaN 149.7 NaN NaN NaN— groupby unstack 版本产品 A-笔记本 B-鼠标 C-键盘 D-显示器城市上海 0.0 299.0 1246.0 0.0北京 49.9 0.0 178.0 0.0广州 0.0 179.4 356.0 0.0杭州 349.3 59.8 0.0 2396.0NaN 149.7 0.0 0.0 0.0对应关系记这一句就够了Excel 透视表 Pandas行标签 index / groupby 的第一个键列标签 columns / groupby 的第二个键 unstack()值 values值字段设置求和/计数/平均 aggfunc / agg两个版本输出几乎一样唯一差别是 unstack(fill_value0) 把没有这个组合填成了 0而 pivot_table 默认留 NaN。0 和 NaN 是两种含义0 表示卖了但金额加起来是 0NaN 表示压根没有这个组合。做报表时想清楚要哪一种别让 0 把没数据掩盖掉。pivot_table 的 aggfunc 也支持一次传多个这时列头会变成两层同样用命名聚合或 reset_index 后的扁平结构更好读。常见问题结果里的城市变成了列头怎么变回普通列说明你拿到的是带索引的 Series 或 DataFrame。加 .reset_index() 即可。多列分组时它会一次还原成两列。分组后有一行组名是 NaN是 bug 吗不是。要么是你加了 dropnaFalse要么是分组列本身有空值。想彻底消失就先用 fillna(“未知”) 把空值填掉再分组报表里显示未知比显示空更清楚。报错 AttributeError: ‘DataFrame’ object has no attribute ‘append’把两个汇总表上下摞起来不要用 append这个方法在 pandas 2.0 就已经从 DataFrame 上移除了改用 pd.concat([表1, 表2], ignore_indexTrue) 或者直接 reset_index() 后合并。下一篇会专门讲。小结这一篇把 Excel 数据透视表完整翻译成了代码groupby 负责拆分组、size/count/sum/mean 负责算、命名聚合负责起名字、reset_index 负责变成能导出的平表、transform 负责把组内统计贴回每一行、pivot_table 负责交叉表。三个必须记住的坑分组列有空值会整行消失记得 dropnaFalse、size 和 count 数的是不同的东西、聚合前先清洗否则一条 -599 的错数据会把整个城市的平均值拉偏。下一篇我们聊表的合并与拼接手上有订单表和客户表两张表怎么按客户 ID 把它们对起来inner / left / right / outer 四种连接有什么区别为什么 merge 之后行数会变多以及上下拼接新一批数据时该怎么写。