写在前面
上一篇我们把脏数据收拾干净了:类型统一、重复行删掉、异常值也标了出来。数据干净之后,下一步就是回答老板最关心的那句——“按城市、按产品分别是多少?”
这就是分组聚合,Excel 里叫数据透视表。用鼠标拖字段当然能做,但每周都要重做一遍就很烦。这篇我们把数据透视表的每一个动作,翻译成 groupby 的代码:单列分组、多列分组、一次算多个统计量、给结果列起中文名、算出每组里最大的那一单、以及组内占比。学完这一篇,社团统计报名人数、课程设计算平均分、工作里做月度报表,都是同一套写法。
先保证示例数据在手边(第 1 篇的 mkdata.py):
import os
import mkdata # 第 1 篇里的 mkdata.py
if 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 pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
print(df.head(4).to_string(index=False))
print(“形状:”, df.shape)
订单日期 城市 产品 数量 单价 销售员 金额2024-01-05 杭州 A-笔记本 3 49.9 小张 149.7
2024-01-06 上海 B-鼠标 5 29.9 小李 149.5
2024-01-06 上海 B-鼠标 5 29.9 小李 149.5
2024-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 pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
print(“— 订单数(size)”)
print(df.groupby(“城市”, dropna=False).size())
print(“— 有金额的订单数(count)”)
print(df.groupby(“城市”, dropna=False)[“金额”].count())
— 订单数(size)
城市
上海 4
北京 3
广州 3
杭州 5
NaN 1
dtype: int64
— 有金额的订单数(count)
城市
上海 4
北京 2
广州 2
杭州 5
NaN 1
Name: 金额, dtype: int64
这里有两个新手最容易混淆的点:
size 数的是行数,count 数的是非空值个数。上海 4 单,但金额有值的只有 4 个(上海这组刚好没有缺失);广州 3 单却只有 2 个有金额,说明广州那组有一行金额是空的。做"订单量"报表用 size,做"有效金额笔数"用 count。
dropna=False 一定要加。城市列为空的那一行,默认会被 groupby 悄悄丢掉(dropna=True 是默认值),报表里就少了 149.7 元。加了 dropna=False,空分组会以 NaN 作为组名保留下来,方便你回头去补数据。
接着把常用的统计量一次看全:
import pandas as pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
print(df.groupby(“城市”, dropna=False)[“金额”].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.0
NaN 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 pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
result = (
df.groupby([“城市”, “产品”], dropna=False)
.agg(销售额=(“金额”, “sum”), 有效订单数=(“金额”, “count”))
.reset_index()
.sort_values(“销售额”, ascending=False)
)
print(result.to_string(index=False))
城市 产品 销售额 有效订单数
杭州 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 1
NaN 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 把全空组算成 0,count 算成 0,而 size 会告诉你这里确实有 1 行数据——三个函数放在一起看,才不会误判。
四、取每组最大/最小的那一行
透视表只能给你一个数字,但业务上更常问的是"每个城市卖得最贵的那一单是谁买的"。这时要用 idxmax 拿到最大值所在的行标签,再用 loc 取回整行:
import pandas as pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
idx = df.groupby(“城市”, dropna=False)[“金额”].idxmax()
print(df.loc[idx, [“订单日期”, “城市”, “产品”, “数量”, “金额”]].to_string(index=False))
订单日期 城市 产品 数量 金额2024-03-15 上海 C-键盘 7 623.0
2024-01-09 北京 C-键盘 2 178.0
2024-05-06 广州 C-键盘 4 356.0
2024-05-20 杭州 D-显示器 5 2995.0
2024-04-18 NaN A-笔记本 3 149.7
idxmax 返回的是原表的行号,df.loc[行号列表] 就能把这些行整行捞出来——这是"分组后取 Top1"最省事的写法。如果不想记 idxmax,第二种写法是先排序再去重:
import pandas as pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
top1 = (
df.sort_values(“金额”, ascending=False)
.drop_duplicates(subset=[“城市”], keep=“first”)
.sort_values(“城市”)
)
print(top1[[“城市”, “产品”, “金额”]].to_string(index=False))
城市 产品 金额
上海 C-键盘 623.0
北京 C-键盘 178.0
广州 C-键盘 356.0
杭州 D-显示器 2995.0
NaN A-笔记本 149.7
两种写法结果一致,区别在:
drop_duplicates 依赖排序,遇到金额相同的两行时,保留的是排序后靠前的那一行,结果不完全确定;
idxmax 遇到并列最大值会报 ValueError,逼你明确处理并列情况。
数据量大时优先 idxmax(它不用把整张表排序),要取每组前 3 名这种需求,则用 df.sort_values(…).groupby(“城市”).head(3)。
五、transform:把"组内占比"贴回每一行
agg 的返回结果是"每组一行",行数和原表对不上,没法直接加回原表。想要"这一单占它所在城市总额的百分之几",要用 transform:
import pandas as pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
df[“城市销售额”] = df.groupby(“城市”, dropna=False)[“金额”].transform(“sum”)
df[“占城市比重%”] = (df[“金额”] / df[“城市销售额”] * 100).round(1)
print(df[[“城市”, “产品”, “金额”, “城市销售额”, “占城市比重%”]].to_string(index=False))
城市 产品 金额 城市销售额 占城市比重%
杭州 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.1
NaN 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.8
transform 和 agg 的唯一区别就是返回长度:agg 给你每组一个值,transform 给每一行都填上它所属组的那个值。所以 df[“城市销售额”] 的长度和 df 完全一致,可以直接做除法,不会出现"赋值后一堆 NaN"的经典事故。
输出里杭州 D-显示器 那两条特别有教学意义:单价 599 的那条占 106.8%,被误录成 -599 的那条是 -21.4%,两条加起来正好 85% 左右——占比超过 100% 或为负数,就是数据有问题的信号。
六、pivot_table:和 groupby 是一回事
如果 Excel 的透视表用惯了,可以直接用 pd.pivot_table,它内部就是 groupby + unstack:
import pandas as pd
df = pd.read_csv(“data/sales.csv”, encoding=“utf-8-sig”)
print(“— pivot_table 版本”)
print(pd.pivot_table(df, index=“城市”, columns=“产品”, values=“金额”, aggfunc=“sum”, dropna=False).round(1).to_string())
print(“— groupby + unstack 版本”)
print(df.groupby([“城市”, “产品”], dropna=False)[“金额”].sum().unstack(fill_value=0).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.0
NaN 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.0
NaN 149.7 0.0 0.0 0.0
对应关系记这一句就够了:
Excel 透视表 Pandas
行标签 index / groupby 的第一个键
列标签 columns / groupby 的第二个键 + unstack()
值 values
值字段设置(求和/计数/平均) aggfunc / agg
两个版本输出几乎一样,唯一差别是 unstack(fill_value=0) 把"没有这个组合"填成了 0,而 pivot_table 默认留 NaN。0 和 NaN 是两种含义:0 表示卖了但金额加起来是 0,NaN 表示压根没有这个组合。做报表时想清楚要哪一种,别让 0 把"没数据"掩盖掉。pivot_table 的 aggfunc 也支持一次传多个,这时列头会变成两层,同样用命名聚合或 reset_index 后的扁平结构更好读。
常见问题
- 结果里的城市变成了"列头",怎么变回普通列?
说明你拿到的是带索引的 Series 或 DataFrame。加 .reset_index() 即可。多列分组时它会一次还原成两列。
- 分组后有一行组名是 NaN,是 bug 吗?
不是。要么是你加了 dropna=False,要么是分组列本身有空值。想彻底消失就先用 fillna(“未知”) 把空值填掉再分组,报表里显示"未知"比显示空更清楚。
- 报错 AttributeError: ‘DataFrame’ object has no attribute ‘append’
把两个汇总表上下摞起来,不要用 append(这个方法在 pandas 2.0 就已经从 DataFrame 上移除了),改用 pd.concat([表1, 表2], ignore_index=True) 或者直接 reset_index() 后合并。下一篇会专门讲。
小结
这一篇把 Excel 数据透视表完整翻译成了代码:groupby 负责拆分组、size/count/sum/mean 负责算、命名聚合负责起名字、reset_index 负责变成能导出的平表、transform 负责把组内统计贴回每一行、pivot_table 负责交叉表。
三个必须记住的坑:分组列有空值会整行消失(记得 dropna=False)、size 和 count 数的是不同的东西、聚合前先清洗,否则一条 -599 的错数据会把整个城市的平均值拉偏。
下一篇我们聊表的合并与拼接:手上有订单表和客户表两张表,怎么按客户 ID 把它们对起来,inner / left / right / outer 四种连接有什么区别,为什么 merge 之后行数会变多,以及上下拼接新一批数据时该怎么写。