分组汇总与透视表

完整讲解 · 8 段教学·配 3 道练习题·预计 45 分钟

本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。

教学 01 / 08

前言

还记得第 1 章那三行 pandas 代码吗?其中有一个 groupby() ,按类别把金额加了起来。不过当时只说了它能做什么,并没有细讲。

这一章,咱们就把它讲透。为什么值得专门花一章来讲呢?因为「每个类别花了多少」「每个月花了多少」「每个月、每个类别各花了多少」,这类问题在数据分析里最常见。而且仔细想想,它们其实都是同一个套路:按某一列分组,每组算一个数,再拼成一张表。

第 4 章里,咱们用 for 循环,把一日三餐挨个算了一遍。学完这一章,这种事一行代码就能做完。

学这一章之前,需要先学完 数据分析课第 5 章 。

目录

这一章会学到:

  • 一、分组汇总——groupby() 在做什么
  • 二、一次算好几个数——agg() ,按结果排序
  • 三、按月汇总——用上日期
  • 四、两个方向一起分——pivot_table()
  • 五、常见坑
  • 六、自己查一次文档——给透视表加上合计
  • 七、小结与回看

学完这一章,记账本里任何「每个……花了多少」的问题,你都能用一两行代码算出来。

教学 02 / 08

一、分组汇总

groupby() 到底做了些什么呢?咱们先回到第 1 章的那一行,把它单独拿出来看:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
print(ledger.groupby('category')['cost'].sum())

输出的结果:

文本
category
交通     1713.0
医疗     1181.5
娱乐     3178.0
学习     1659.0
居住    32888.5
购物     4992.5
餐饮    23583.0
Name: cost, dtype: float64

别看只有一行,它其实做了三件事:

  1. 分:groupby('category') 按类别,把一千多行分成 7 堆:交通一堆,医疗一堆……
  2. 算:['cost'].sum() 从每一堆里各自取出金额,加起来。
  3. 合:把 7 个结果拼成一个 Series,类别就成了左边的索引。

你也许注意到了,结果是按类别的名字排的,而不是按金额。这是因为 groupby() 默认会把分组的名字排好序。想按金额排,后面再接一个 sort_values() 就行,第 1 章就是这么做的。

那么,「算」这一步只能求和吗?当然不是,换成别的方法也行。下面这段代码,就换了两种算法:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
print(ledger.groupby('item')['cost'].mean().round(2).head(4))
print(ledger.groupby('category').size())

输出的结果:

文本
item
书       59.81
午饭     26.40
咖啡     22.24
唱歌    105.23
Name: cost, dtype: float64
category
交通    241
医疗      7
娱乐     46
学习     11
居住     36
购物     29
餐饮    984
dtype: int64

上面打印了两个结果,咱们分开来看:

  • 前一个是按名称分组,再求平均,算的是每种花销平均一笔多少钱。第 4 章用 for 循环才算出三餐的平均,这里一行就全算出来了。
  • 后一个用的是 size() ,它数的是每一组有几行,所以不用取哪一列。结果和第 4 章的 value_counts() 一样,只是按名字排了序。

总结一下就是:groupby() 后面接什么方法,每一组就算什么。sum() 、mean() 、median() 、max() 、count() ……前几章学过的这些,在这里都能用。

不过到现在为止,每次都只算了一个数。要是想同时看好几个数呢?下一节就来解决。

教学 03 / 08

二、一次算好几个数

上一节,groupby() 后面每次只接一个方法,一次只算一个数。可真正分析的时候,常常想同时知道好几个数。比如每个类别,既想知道记了几笔,又想知道合计和平均。

分三次写,当然也能算出来,就是太啰嗦了。这时候可以用 agg() ,一次全算完。用法是在括号里放一个列表,列表里写上要用的方法名:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
summary = ledger.groupby('category')['cost'].agg(['count', 'sum', 'mean']).round(2)
print(summary)

输出的结果:

文本
          count      sum    mean
category                        
交通        241   1713.0    7.11
医疗          7   1181.5  168.79
娱乐         46   3178.0   69.09
学习         11   1659.0  150.82
居住         36  32888.5  913.57
购物         29   4992.5  172.16
餐饮        984  23583.0   23.97

和上一节比一比,结果有什么不一样?上一节只算一个数,结果是一个 Series。这回算了好几个数,结果就成了一张 DataFrame:每个方法占一列,列名就是方法名。

不过,这张表是按类别的名字排的,谁多谁少看不出来。要是按合计排一下序,就好读多了。要注意的是,DataFrame 有好几列,所以它的 sort_values() 得说明按哪一列来排:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
summary = ledger.groupby('category')['cost'].agg(['count', 'sum', 'mean']).round(2)
print(summary.sort_values('sum', ascending=False))

输出的结果:

文本
          count      sum    mean
category                        
居住         36  32888.5  913.57
餐饮        984  23583.0   23.97
购物         29   4992.5  172.16
娱乐         46   3178.0   69.09
交通        241   1713.0    7.11
学习         11   1659.0  150.82
医疗          7   1181.5  168.79

排好序以后,把三列放在一起看,就能看出每个类别的「性格」:

  • 餐饮:984 笔,每笔平均 24 元。笔数多、单笔小,钱是一点一点花出去的。
  • 交通:241 笔,平均每笔才 7 元,大多是坐地铁,一年合计还不到 2000 元。
  • 学习、医疗:一年加起来才十来笔,每笔却要一百多元。也就是说,次数少,单笔大。

那么,想省钱的话,该从哪儿下手呢?单笔小、次数多的餐饮,每顿省一点,就能积少成多;单笔大的购物,少买一两件,效果立竿见影。

到这里,分组都是按类别来分的。可要是想知道每个月花了多少呢?下一节就来按月分组。在那之前,先做一道练习:换成按付款方式分组,看看每种付款方式平均一笔多少钱。

练习 1 / 3每种付款方式,平均一笔多少

读进记账本,按付款方式(payment)分组,算出每种付款方式平均一笔多少钱,保留两位小数,从大到小排序后打印。

运行后的输出应该是:

文本
payment
银行卡    186.18
支付宝     33.00
微信       26.54
现金       23.88
Name: cost, dtype: float64
💡 提示(3 条)
  1. groupby('payment')['cost'].mean()

  2. 后面接 .round(2).sort_values(ascending=False)

  3. 完整代码:

    Python
    import pandas as pd
    
    ledger = pd.read_csv('ledger_2026.csv')
    print(ledger.groupby('payment')['cost'].mean().round(2).sort_values(ascending=False))
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
教学 04 / 08

三、按月汇总

「每个月花了多少?」按月分组,听上去和按类别分组差不多。可翻一翻记账本就会发现,里面根本没有「月」这一列,想分也没得分。怎么办呢?那就先造一列出来。

要用到的东西,第 5 章都学过:pd.to_datetime() 能把日期变成真正的日期,.dt.month 能从日期里取出月份。那新的一列,又怎么加进表里呢?其实和字典添加新键一样,直接赋值就有了:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
ledger['date'] = pd.to_datetime(ledger['date'])
ledger['month'] = ledger['date'].dt.month
print(ledger.head(3))

输出的结果:

文本
        date      item category    cost payment  month
0 2026-01-01      房租     居住  2500.0  银行卡      1
1 2026-01-01  水电燃气     居住   156.0  支付宝      1
2 2026-01-01      早饭     餐饮    13.0  支付宝      1

看最右边,多出了一列 month 。有了它,按月分组就和按类别分组一模一样了:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
ledger['date'] = pd.to_datetime(ledger['date'])
ledger['month'] = ledger['date'].dt.month
print(ledger.groupby('month')['cost'].sum())

输出的结果:

文本
month
1     5289.5
2     5272.5
3     5418.5
4     5799.0
5     5712.0
6     5485.5
7     5700.0
8     6501.5
9     5576.5
10    5426.0
11    6034.5
12    6980.0
Name: cost, dtype: float64

月份是数字,所以按 1 到 12 排好了,正好就是时间的先后顺序。

从结果看,大多数月份都在五千多元,只有三个月超过了六千:8 月、11 月和 12 月。其中 12 月最多,将近七千元。

那这三个月,多出来的钱都花在哪儿了呢?光看这一列,是看不出来的,因为它只按月分了组,没有分类别。要回答这个问题,得把「月」和「类别」两个方向放在一起看,这正是下一节要讲的。在那之前,先做一道练习:找出花得最少的三个月。

练习 2 / 3花得最少的三个月

读进记账本,造出月份这一列,按月把金额加起来,再从小到大排序,打印花得最少的三个月。

运行后的输出应该是:

文本
month
2    5272.5
1    5289.5
3    5418.5
Name: cost, dtype: float64
💡 提示(3 条)
  1. 月份列:pd.to_datetime(ledger['date']).dt.month

  2. sort_values() 默认从小到大,再接 .head(3)

  3. 完整代码:

    Python
    import pandas as pd
    
    ledger = pd.read_csv('ledger_2026.csv')
    ledger['month'] = pd.to_datetime(ledger['date']).dt.month
    monthly = ledger.groupby('month')['cost'].sum()
    print(monthly.sort_values().head(3))
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
教学 05 / 08

四、两个方向一起分

上一节留下了一个问题:8 月、11 月、12 月多出来的钱,都花在哪儿了?要回答它,就得知道「每个月、每个类别,各花了多少」。这回要按两列来分组,一个方向是月份,另一个方向是类别。

这时候就轮到 pivot_table() 出场了。它会把结果排成一张横着看、竖着看都有意义的表,这种表叫透视表:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
ledger['month'] = pd.to_datetime(ledger['date']).dt.month
table = ledger.pivot_table(index='month', columns='category', values='cost', aggfunc='sum')
print(table)

输出的结果:

文本
category   交通   医疗   娱乐   学习    居住    购物    餐饮
month                                                       
1         200.0    NaN  240.0    NaN  2714.0     NaN  2135.5
2         115.5    NaN  387.0    NaN  2760.5   222.0  1787.5
3         202.5    NaN  394.0    NaN  2720.0   181.0  1921.0
4         144.5  134.0  170.0   71.0  2763.5   517.5  1998.5
5         117.0  384.0  287.0    NaN  2687.5   337.0  1899.5
6         159.0   83.5  126.0  166.5  2812.5   404.0  1734.0
7         108.5    NaN  109.0    NaN  2725.0   497.0  2260.5
8          85.5    NaN  322.0  500.0  2799.0   852.5  1942.5
9         126.0    NaN  407.5  455.0  2689.0   118.5  1780.5
10        103.0    NaN  346.5  160.0  2727.0   113.0  1976.5
11        106.5  216.5  190.0  164.0  2787.5   577.0  1993.0
12        245.0  363.5  199.0  142.5  2703.0  1173.0  2154.0

这段代码里,最要紧的是 pivot_table() 括号里的四个参数,它们各管一件事:

参数意思这里写的
index哪一列的值做成行月份
columns哪一列的值做成列类别
values格子里放哪一列算出来的数金额
aggfunc怎么算求和

所以这张表,每一行是一个月,每一列是一个类别,格子里就是那个月、那一类花了多少钱。

有些格子里是 NaN ,这是因为那个月没有这一类的花销。比如 1 月,既没看病,也没买学习的东西。

有了这张表,就能回答上一节的问题了。顺着 8 月、11 月、12 月这三行看过去:

  • 8 月:学习 500 元,购物 852.5 元,都比平时多。
  • 11 月:看病 216.5 元,购物 577 元。
  • 12 月:购物 1173 元,是全年购物最多的一个月;看病 363.5 元,餐饮、交通也比平时多一些。

再竖着看居住这一列,每个月都在 2700 元上下,雷打不动。所以,真正让月和月拉开差距的,是购物、学习、医疗这些「不是每个月都有」的花销。

你看,一行 pivot_table() ,得到一张表,就把一年的钱是怎么花的,摊开在眼前了。

教学 06 / 08

五、常见坑

groupby() 和 pivot_table() 用起来很顺手。不过刚开始用的时候,有几个坑很容易踩到,咱们一个一个来看。

1、分组以后忘了取列

先看一段代码,它想算的是每个类别的平均:

Python· 预期报错示例
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
print(ledger.groupby('category').mean())

一运行就报错了,报错信息的最后一行是:

文本
TypeError: agg function failed [how->mean,dtype->object]

为什么会报错呢?因为 groupby('category') 后面没有写 ['cost'] ,pandas 就会把每一列都拿来求个平均,日期、名称、付款方式这些文字也不例外。可文字是没法求平均的,所以就报错了。

报错里的 dtype->object 就是线索:它说明有 object 类型的列被拿去算了。所以要记住,分组以后,先取出要算的那一列。

2、分组的列成了索引

分组算完以后,用来分组的那一列跑到哪儿去了呢?看下面这段:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
totals = ledger.groupby('category')['cost'].sum()
print(totals['餐饮'])
print(totals.reset_index().head(3))

输出的结果:

文本
23583.0
  category    cost
0     交通  1713.0
1     医疗  1181.5
2     娱乐  3178.0

原来,分组以后,类别就不再是普通的一列了,而是跑到了左边,成了索引。这样有好处,也有麻烦。

好处是可以按名字取值,比如 totals['餐饮'] ,一下就取出了餐饮的合计。麻烦的是,要是还想把类别当成一列来用,比如接着筛选、存成 csv,就不方便了。这时候用 reset_index() 把它放回去就行:类别重新变回一列,行号也重新从 0 开始。

3、空格子补成 0,要看算的是什么

透视表里那些 NaN ,看着有点别扭。其实 pivot_table() 有个参数叫 fill_value ,可以把空格子填成一个值:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
ledger['month'] = pd.to_datetime(ledger['date']).dt.month
table = ledger.pivot_table(index='month', columns='category', values='cost', aggfunc='sum', fill_value=0)
print(table.loc[1:3])

输出的结果:

文本
category   交通  医疗   娱乐  学习    居住   购物    餐饮
month                                                    
1         200.0   0.0  240.0   0.0  2714.0    0.0  2135.5
2         115.5   0.0  387.0   0.0  2760.5  222.0  1787.5
3         202.5   0.0  394.0   0.0  2720.0  181.0  1921.0

这里算的是求和。没有花销,就是花了 0 元,所以填 0 没问题。

可要是换成 aggfunc='mean' 呢?这时候格子里算的是平均每笔多少钱。那个月一笔都没有,平均数就不是 0,而是「没法算」。这时候要是填了 0,再拿这张表去算全年的平均,结果就会被这些 0 拉低。

所以,空格子填不填、填什么,要看格子里算的是什么。

坑看完了,先做一道练习:做一张透视表,数一数每个类别都是用什么方式付的钱。

练习 3 / 3每个类别用什么付钱

读进记账本,做一张透视表:每一行是一个类别,每一列是一种付款方式,格子里是笔数,没有的填 0。打印出来。

aggfunc 写 'count' 就是数个数。

运行后的输出应该是:

文本
payment   微信  支付宝  现金  银行卡
category                            
交通       121      86     1      33
医疗         5       2     0       0
娱乐        23      20     1       2
学习         4       4     0       3
居住         0      24     0      12
购物         8      18     0       3
餐饮       414     428    10     132
💡 提示(3 条)
  1. index='category', columns='payment', values='cost'

  2. 再加上 aggfunc='count', fill_value=0

  3. 完整代码:

    Python
    import pandas as pd
    
    ledger = pd.read_csv('ledger_2026.csv')
    table = ledger.pivot_table(index='category', columns='payment', values='cost', aggfunc='count', fill_value=0)
    print(table)
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
教学 07 / 08

六、自己查一次文档

透视表有了,可是好像还差点什么。差什么呢?差的是合计:每个月一共花了多少,每个类别全年花了多少,也就是每一行、每一列加起来的总数。

自己算当然可以,按月、按类别各 groupby() 一次就行。不过,「给表格加个合计」是这么常见的需求,pivot_table() 说不定自己就带了这个功能。去文档里查一查:DataFrame.pivot_table 。

打开以后,看参数列表。fill_value 上一节已经用过了,紧挨着它的是 margins 。它的说明是:设成 True ,就会加上一行一列,名字叫 All ,里面放的是各行、各列汇总起来的结果。为什么叫这个名字呢?margin 的本意是「页边」,而合计正好写在表格的边上。

再往下看,还有一个 margins_name 。它管的是合计那一行、那一列叫什么名字,默认是 'All' 。

查到了这两个参数,就一起用上试试:

Python· 可在网页运行
import pandas as pd

ledger = pd.read_csv('ledger_2026.csv')
ledger['month'] = pd.to_datetime(ledger['date']).dt.month
table = ledger.pivot_table(index='month', columns='category', values='cost',
                           aggfunc='sum', fill_value=0, margins=True, margins_name='合计')
print(table)

输出的结果:

文本
category    交通    医疗    娱乐    学习     居住    购物     餐饮     合计
month                                                                      
1          200.0     0.0   240.0     0.0   2714.0     0.0   2135.5   5289.5
2          115.5     0.0   387.0     0.0   2760.5   222.0   1787.5   5272.5
3          202.5     0.0   394.0     0.0   2720.0   181.0   1921.0   5418.5
4          144.5   134.0   170.0    71.0   2763.5   517.5   1998.5   5799.0
5          117.0   384.0   287.0     0.0   2687.5   337.0   1899.5   5712.0
6          159.0    83.5   126.0   166.5   2812.5   404.0   1734.0   5485.5
7          108.5     0.0   109.0     0.0   2725.0   497.0   2260.5   5700.0
8           85.5     0.0   322.0   500.0   2799.0   852.5   1942.5   6501.5
9          126.0     0.0   407.5   455.0   2689.0   118.5   1780.5   5576.5
10         103.0     0.0   346.5   160.0   2727.0   113.0   1976.5   5426.0
11         106.5   216.5   190.0   164.0   2787.5   577.0   1993.0   6034.5
12         245.0   363.5   199.0   142.5   2703.0  1173.0   2154.0   6980.0
合计      1713.0  1181.5  3178.0  1659.0  32888.5  4992.5  23583.0  69195.5

这张表比原来多了一列和一行,咱们来对一对:

  • 最右边一列,是每个月的合计,和第三节按月汇总的结果一样。
  • 最下面一行,是每个类别全年的合计,和第一节算出来的一样。
  • 右下角那一格,是全年的总额,69195.5 元。

另外,这行代码太长,所以在括号里换了一行。这样换行为什么不会出错呢?因为括号还没关上,Python 就知道这句话还没说完。

最后说一个查文档的小窍门。文档里的参数,往往是「两两成对」的:一个负责打开某个功能,另一个负责调整它的细节。所以看到 margins ,顺手往下多看几行,就找到了 margins_name 。

教学 08 / 08

七、小结与回看

这一章,咱们从第 1 章那一行 groupby() 出发,先按类别、再按月份,最后两个方向一起分,把记账本里「每个……花了多少」的问题都算了出来。回头梳理一下,学到了这几件事:

  • groupby('列名')['要算的列'].方法() :先分成几组,每组各算一个数,再拼起来。结果按组名排序,要按数值排,就再接一个 sort_values() 。
  • size() 数每组有几行。agg(['count', 'sum', 'mean']) 一次算好几个数,结果是一张表;DataFrame 的 sort_values('列名') 按某一列来排序。
  • 造新的一列:ledger['month'] = ... ,直接赋值就行。配合 .dt.month ,就能按月汇总。
  • pivot_table(index=..., columns=..., values=..., aggfunc=...) 按两个方向一起分组,得到透视表。
  • 常见的坑有三个:分组以后忘了取列,文字列也被拿去算;分组的列成了索引,要用 reset_index() 放回去;用 fill_value 填空格子,要看格子里算的是什么。
  • 查文档学会了用 margins=True 、margins_name 给透视表加上合计。

哪里卡住了,就回去看一看:

  • 字典添加新的键:第 4 章
  • 用 for 循环做的三餐对比:数据分析课第 4 章的「四、标准差」

不过到这里,咱们每次分析的都只是一张表。可有些问题,光靠一张表回答不了。比如两点书店的数据分成了书目和评分两张表,想知道「哪一类书评分最高」,就得先把它们合并起来。下一章,就来学怎么把几张表拼成一张。

读完了?动手练一遍才算真会。

去闯关模式练习 →

学习目录