返回首页

Roadmap
更新于 2026年10月24日 全文 1939 字未阅读

第 4.1 节 核心知识点#

预计学习时长: 45 分钟

本节目标:

  • 知道 groupby() 到底做了什么(拆 → 算 → 拼);
  • 会用 sum / mean / count / size / max / min 做统计;
  • 会用 agg() 给不同列指定不同的聚合函数;
  • 分得清 agg()(每组出一个值)和 transform()(每组出一个值再广播回每一行);
  • 会用 pivot_table() 做二维汇总、crosstab() 做交叉频数;
  • 会按多个字段做多级分组。

先说清楚一件事:groupby 到底做了什么#

一句话:拆 → 算 → 拼。

准备数据:

import pandas as pd

data = {
    "订单号":   [1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008],
    "下单日期": ["2024-01-05", "2024-01-08", "2024-01-12", "2024-02-03",
                 "2024-02-15", "2024-03-02", "2024-03-20", "2024-04-08"],
    "城市":     ["北京", "上海", "北京", "广州", "上海", "北京", "深圳", "广州"],
    "商品":     ["手机", "电脑", "耳机", "手机", "键盘", "显示器", "耳机", "手机"],
    "销量":     [2, 1, 3, 2, 5, 1, 4, 2],
    "单价":     [3999.0, 7999.0, 599.0, 3999.0, 299.0, 1299.0, 599.0, 3999.0],
    "金额":     [7998.0, 7999.0, 1797.0, 7998.0, 1495.0, 1299.0, 2396.0, 7998.0],
    "客户评分": [4.5, 4.0, 4.8, 3.9, 4.2, 4.6, 4.1, 4.9],
}
df = pd.DataFrame(data)

我们想知道「每个城市一共卖了多少钱」。

Excel 里的做法:插入 → 数据透视表 → 城市拖到行标签,金额拖到值。

Pandas 里的做法:

result = df.groupby("城市")["金额"].sum()
print(result)

输出:

城市
上海     9494.0
北京    11094.0
广州    15996.0
深圳     2396.0
Name: 金额, dtype: float64

背后的三个步骤:

  1. 拆——按「城市」把 8 行拆成 4 组:北京 3 行、上海 2 行、广州 2 行、深圳 1 行;
  2. 算——每一组单独把「金额」加起来;
  3. 拼——把 4 个和拼成一张表,「城市」变成了索引。

注意结果的两点变化

  1. 结果是 Series,不是 DataFrame(因为你只统计了一列);
  2. 「城市」不再是普通列,变成了索引。

这两点在第 2 章讲过,现在你会知道它们是 groupby 的必然结果。想让「城市」变回列,后面会讲 reset_index()。

分组键可以是一列,也可以是多列#

df.groupby("城市")              # 按一列分
df.groupby(["城市", "商品"])     # 按两列分

分组键可以是函数、字典,甚至数组#

# 按金额高低分两档
df.groupby(df["金额"] > 5000)

# 用字典做映射分组
df.groupby({"城市": "区域", "商品": "品类"}.get)

这些比较进阶,先记住有这回事就行。

知识点 1:GroupBy Basics(分组操作基础)#

三种写法,结果完全一样#

df.groupby("城市")["金额"].sum()        # 推荐:先分组再选列
df.groupby("城市").sum()["金额"]         # 先全表聚合再选列(会算所有数值列)
df["金额"].groupby(df["城市"]).sum()    # 老写法,不推荐

用第一种 df.groupby("列")["要算的列"].聚合()。 第二种会把「销量」「单价」「客户评分」全都算一遍,慢而且结果里一堆你不需要的列。

看每个组有几行:size()#

print(df.groupby("城市").size())

输出:

城市
上海    2
北京    3
广州    2
深圳    1
dtype: int64

为什么顺序是「上海、北京、广州、深圳」?#

groupby() 默认会按分组键排序(参数 sort=True)。而中文的排序是按 Unicode 码点来的,「上」的码点比「北」小,所以「上海」排第一。

想保持原始出现顺序,加 sort=False:

df.groupby("城市", sort=False).size()
# 北京 3, 上海 2, 广州 2, 深圳 1

和 value_counts() 对比一下

df["城市"].value_counts()
# 北京 3, 上海 2, 广州 2, 深圳 1

value_counts() 是按数量从多到少排的,而且结果里分组键是普通列不是索引。 要计数的话,两种都行;但记住它们的排序规则不一样,别被绕晕。

知识点 2:Aggregation Functions(聚合函数)#

常用的六个#

g = df.groupby("城市")["金额"]

g.sum()    # 总和
g.mean()   # 平均值
g.count()  # 计数(跳过缺失值)
g.size()   # 分组大小(不跳缺失值)
g.max()    # 最大值
g.min()    # 最小值

一次看全:

print(df.groupby("城市")["金额"].agg(["count", "mean", "sum", "max", "min"]))

输出:

    count   mean      sum     max     min
城市
上海    2  4747.0   9494.0  7999.0  1495.0
北京    3  3698.0  11094.0  7998.0  1299.0
广州    2  7998.0  15996.0  7998.0  7998.0
深圳    1  2396.0   2396.0  2396.0  2396.0

传一个列表进去,就能一次算多个统计量——这是最常用的写法。

count() 和 size() 有什么区别?#

这是个高频问题。假设「金额」里有一个缺失值:

import numpy as np
d = df.copy()
d.loc[1, "金额"] = np.nan

print(d.groupby("城市")["金额"].count())   # 上海少了一个
print(d.groupby("城市").size())            # 全部照常

输出:

城市
上海    1
北京    3
广州    2
深圳    1
dtype: int64
城市
上海    2
北京    3
广州    2
深圳    1
dtype: int64

原因:

  • count() 统计的是「这一列里有多少个非空值」,NaN 不算数;
  • size() 统计的是「这个组有几行」,不管单元格里有没有值。

选择原则

  • 想统计「每组有几条记录」→ 用 size()(或者 count() 但要确保没缺失值);
  • 想统计「每组有几家客户填了表」→ 用 count()。

一旦你关心的是「笔数」,size() 更安全,因为它不会被缺失值干扰。

换别的聚合函数#

除了内置的,还可以传自己的函数:

# 求最大值和最小值的差(极差)
df.groupby("城市")["金额"].agg(lambda x: x.max() - x.min())

输出:

城市
上海    6504.0
北京    6699.0
广州       0.0
深圳       0.0
dtype: float64

agg():给不同列指定不同的聚合函数#

当你要一次算好几列、而且每列的算法不一样时,用 agg() 传字典:

print(df.groupby("城市").agg({"金额": "sum", "销量": "mean"}))

输出:

         金额   销量
城市
上海  9494.0  3.0
北京  11094.0  2.0
广州  15996.0  2.0
深圳  2396.0  4.0

同一列要多个统计量,传元组列表:

print(df.groupby("城市").agg({"金额": ["sum", "mean"], "销量": "sum"}))

输出:

         金额          销量
        sum   mean    sum
城市
上海  9494.0  4747.0     6
北京  11094.0  3698.0     6
广州  15996.0  7998.0     4
深圳  2396.0  2396.0     4

想让输出列名更好看,用命名聚合(注意值是元组):

print(df.groupby("城市").agg(总金额=("金额", "sum"), 订单数=("订单号", "count")))

输出:

        总金额  订单数
城市
上海   9494.0     2
北京  11094.0     3
广州  15996.0     2
深圳   2396.0     1

命名聚合的写法是 agg(新列名=("原列名", "函数"))——值必须是元组。 写成 agg(总金额="sum") 会报 TypeError: Must provide 'func' or tuples of '(column, aggfunc)'。

agg() 和 transform() 有什么区别?#

这是本章第二大坑。

  • agg():每组算出一个值,组里有几行就出几行结果;
  • transform():每组算出一个值,然后广播回组里的每一行,结果长度和原表一样。

先看 agg():

print(df.groupby("城市")["金额"].agg("sum"))
城市
上海     9494.0
北京    11094.0
广州    15996.0
深圳     2396.0

4 行(4 个城市)。

再看 transform():

print(df.groupby("城市")["金额"].transform("sum"))
0    11094.0
1     9494.0
2    11094.0
3    15996.0
4     9494.0
5    11094.0
6     2396.0
7    15996.0

8 行,和原表一样长。第 0 行是北京,值是北京的总金额 11094;第 1 行是上海,值 9494……

transform() 最大的用途是「造新列」:

df = df.copy()
df["城市总金额"] = df.groupby("城市")["金额"].transform("sum")
print(df[["城市", "金额", "城市总金额"]])
   城市     金额  城市总金额
0  北京  7998.0   11094.0
1  上海  7999.0    9494.0
2  北京  1797.0   11094.0
3  广州  7998.0   15996.0
4  上海  1495.0    9494.0
5  北京  1299.0   11094.0
6  深圳  2396.0    2396.0
7  广州  7998.0   15996.0

现在你可以直接算「这笔订单占本城市总金额的比例」:

df["占比"] = df["金额"] / df["城市总金额"]
print(df[["城市", "金额", "占比"]].round(3))
   城市     金额    占比
0  北京  7998.0  0.721
1  上海  7999.0  0.843
2  北京  1797.0  0.162
3  广州  7998.0  0.500
4  上海  1495.0  0.157
5  北京  1299.0  0.117
6  深圳  2396.0  1.000
7  广州  7998.0  0.500

一句话选型

  • 要汇总成一张小表(每个组一行)→ agg()
  • 要保留原表每一行,只是加上组级别的信息 → transform()

知识点 3:Pivot Tables(透视表)#

groupby() 是「一维」分组——只按一个维度分。透视表是「二维」——同时按行和列分。

print(pd.pivot_table(df, index="城市", columns="商品", values="金额", aggfunc="sum"))

输出:

商品       手机     显示器      电脑      耳机      键盘
城市                                         
上海      NaN     NaN  7999.0     NaN  1495.0
北京   7998.0  1299.0     NaN  1797.0     NaN
广州  15996.0     NaN     NaN     NaN     NaN
深圳      NaN     NaN     NaN  2396.0     NaN

读法:行是城市,列是商品,格子里的数字是金额之和。

NaN 是怎么回事?#

北京没有卖过「电脑」,所以那个格子是空的。这是透视表的正常行为——没有数据的位置就是 NaN。

想让空格子显示成 0,加 fill_value=0:

print(pd.pivot_table(df, index="城市", columns="商品", values="金额",
                     aggfunc="sum", fill_value=0))
商品       手机     显示器      电脑      耳机      键盘
城市                                         
上海      0.0     0.0  7999.0     0.0  1495.0
北京   7998.0  1299.0     0.0  1797.0     0.0
广州  15996.0     0.0     0.0     0.0     0.0
深圳      0.0     0.0     0.0  2396.0     0.0

四个核心参数#

pd.pivot_table(
    df,              # 数据源
    index="城市",     # 行:按谁分行
    columns="商品",   # 列:按谁分列
    values="金额",    # 值:算什么
    aggfunc="sum"    # 用什么函数算
)
参数作用
index行的分组依据
columns列的分组依据
values要被聚合的列
aggfunc聚合方式,默认是 "mean"

aggfunc 的默认值是「平均」不是「求和」!

pd.pivot_table(df, index="城市", values="金额")                # 默认 mean
pd.pivot_table(df, index="城市", values="金额", aggfunc="sum") # 求和

这个默认值经常坑到人。写透视表时,养成习惯把 aggfunc 明确写出来。

加一行合计#

print(pd.pivot_table(df, index="城市", values="金额", aggfunc="sum",
                     margins=True, margins_name="合计"))
         金额
城市         
上海   9494.0
北京  11094.0
广州  15996.0
深圳   2396.0
合计  38980.0

margins=True 加合计,margins_name="合计" 给它起个中文名(默认叫 "All")。

一次算多列#

print(pd.pivot_table(df, index="城市", values=["金额", "销量"], aggfunc="sum"))
         金额  销量
城市             
上海  9494.0   6
北京  11094.0   6
广州  15996.0   4
深圳  2396.0   4

知识点 4:Cross Tabulation(交叉表)#

如果只关心「频数」(各组合出现了多少次),crosstab() 更直接:

print(pd.crosstab(df["城市"], df["商品"]))

输出:

商品  手机  显示器  电脑  耳机  键盘
城市                     
上海   0    0    1    0    1
北京   1    1    0    1    0
广州   2    0    0    0    0
深圳   0    0    0    1    0

读法:上海有 1 笔电脑、1 笔键盘;广州有 2 笔手机。

对照一下 pivot_table 做的「金额求和」,crosstab 做的是「数个数」。

crosstab 还能算比例#

print(pd.crosstab(df["城市"], df["商品"], normalize="index").round(2))
商品    手机  显示器  电脑  耳机  键盘
城市                        
上海   0.00   0.00  0.50  0.00  0.50
北京   0.33   0.33  0.00  0.33  0.00
广州   1.00   0.00  0.00  0.00  0.00
深圳   0.00   0.00  0.00  1.00  0.00

normalize="index" 是「按行归一化」——每一行加起来等于 1。上面表示:广州的订单 100% 是手机,北京的订单里手机、显示器、耳机各占三分之一。

还可以 normalize="columns"(按列归一化)或 normalize="all"(按总计归一化)。

crosstab 和 pivot_table 怎么选?#

crosstab()pivot_table()
默认算什么频数平均值(aggfunc="mean")
主要用途看两个分类变量的组合分布做带数值汇总的报表
参数风格crosstab(行, 列)pivot_table(df, index=, columns=, values=, aggfunc=)

速记

  • 只想知道「有多少笔」→ crosstab()
  • 想知道「每格是多少钱、卖了多少件」→ pivot_table()

知识点 5:Multi-Level GroupBy(多级分组)#

按多个字段分组,就是「先按城市分,组内再按商品分」。

print(df.groupby(["城市", "商品"])["金额"].agg(["count", "sum"]))

输出:

            count      sum
城市 商品                
上海 电脑        1  7999.0
    键盘        1  1495.0
北京 手机        2  7998.0
    显示器        1  1299.0
    耳机        1  1797.0
广州 手机        2  15996.0
深圳 耳机        1  2396.0

注意结果索引是两层的:外层是城市,内层是商品。层级之间有一个空行,那是为了让你看清分组边界。

层级索引怎么取用?#

g = df.groupby(["城市", "商品"])["金额"].sum()

g.loc["北京"]            # 取北京这一组(内层索引被保留)
g.loc["北京", "手机"]     # 取北京-手机这一组
g.loc["北京"].sum()       # 汇总北京下面所有商品
g.index.levels[0]         # 第 0 层:城市列表

换回普通列#

print(df.groupby(["城市", "商品"])["金额"].sum().reset_index())
   城市  商品     金额
0  上海  电脑  7999.0
1  上海  键盘  1495.0
2  北京  手机  7998.0
3  北京  显示器  1299.0
4  北京  耳机  1797.0
5  广州  手机  15996.0
6  深圳  耳机  2396.0

reset_index() 把两层索引都变回普通列——第 2 章讲过,这里是它的主要用途之一。

分组结果里的缺失值#

分组统计时,某组某个值缺失会让这一组的统计变 NaN。这是 Pandas 聚合函数的默认行为(skipna=True 表示跳过缺失值再算)。

比如「金额」里有一格是 NaN,北京组的 mean 只会拿剩下的值算:

d = df.copy()
d.loc[1, "金额"] = np.nan
print(d.groupby("城市")["金额"].mean())
城市
上海    1495.0
北京    3698.0
广州    7998.0
深圳    2396.0
dtype: float64

上海的金额本来有 7999.0 和 1495.0 两个,现在一个是 NaN。mean() 跳过 NaN,只拿 1495.0 一个来算,平均值就是 1495.0。

mean() 默认跳过缺失值(参数 skipna=True),所以有缺失时它的分母会变小——这通常正是你想要的。 但如果你希望「把缺失当 0 一起算」,得先 fillna(0) 再分组;如果你想看的是「几家客户填了表」,用 count() 而不是 mean()。

本节小结#

  • groupby() = 拆 → 算 → 拼,结果里分组键变成索引。
  • 常用聚合:sum mean count size max min;传列表可以一次算多个。
  • count() 数的是「非空值个数」,size() 数的是「行数」——要数笔数就用 size()。
  • agg() 给不同列不同算法:传字典;命名聚合写成 agg(新名=("原列名", "函数"))。
  • agg() 每组出一个值(结果变短),transform() 每组出一个值再广播回每行(结果不变长)。
  • pivot_table() 是二维透视表,aggfunc 默认是 mean 不是 sum,记得显式写。
  • crosstab() 专门数频数,支持 normalize 算比例。
  • 多级分组用 groupby(["A", "B"]),结果是两层索引,reset_index() 可以摊平。

下一节预告 现在你能从一张表做出汇总报表了。但如果报表要「合上另一张表」的数据——比如订单表要带上客户表里的客户名——就需要第 5 章的 merge()。而当你要把「每个季度一列」的长表变成「每行一个季度」的明细时,melt() 就派上用场了。

阅读 --