☰
Pandas分组聚合实战:用groupby、agg、round绘制频率分布直方图
2026/10/18 18:09:30 网站建设 项目流程

做数据分析时间长了你会发现,很多刚入门的朋友一上来就研究各种高级模型、复杂算法,结果拿到一份实际业务数据反而手足无措。反倒是那些最基础的操作——分组、聚合、算频率、画分布——才是日常工作中最高频使用的技能。今天借“定量分析—频率分布直方图”这个话题,把groupby、agg、round这几个 Pandas 里的常用方法彻底讲透。

先说明白这篇文章适合谁看:刚接触 Python 数据分析、对 Pandas 只了解个大概、想系统搞清楚分组聚合和频数统计的初学者。文章不会堆砌晦涩术语,所有概念都用“生活类比 + 代码实操”的方式拆开讲,保证你跟着敲一遍就能在自己项目里用起来。

1. 定量分析与频率分布直方图的设计思路

1.1 为什么要做频率分布分析

先说一个最基础的问题:拿到一份数据,你第一个想知道的是什么?大部分人的回答是“算平均值”,比如销售额的平均值、用户年龄的平均值。但平均值有一个致命的缺陷——它会被极端值拉扯。举一个最简单的例子:一个班级里 9 个学生考试分数是 60 到 80 分,只有 1 个学霸考了 100 分,这时候平均分可能到了 75 分左右。光看平均值,你会觉得这个班成绩还行,但实际上大部分人都在及格线附近挣扎。

频率分布直方图解决的就是这个问题。它不关心“平均”这个概念,而是把数据切成若干个区间,统计每个区间里有多少条记录。这样一来,数据的真实分布形态就一目了然:是集中在一块还是分散得很开?是左偏还是右偏?有没有明显的双峰结构?这些信息对后续的决策——比如制定销售策略、划分用户等级、设置告警阈值——都至关重要。

用 Pandas 做这件事的核心链路就三步:groupby切片分组、agg聚合计算、round控制精度。你可能奇怪,画直方图不是有现成的hist()函数吗?为什么要绕这么大一圈?这里我多解释一句:hist()适合快速探索性分析,但如果你要做定量输出——比如把每个区间频数存成表格、按另一个维度对比分布、或者把结果喂给下一步计算,就必须手动用groupby + agg把频数表算出来。这也是为什么这类题目在面试和实际工作中都经常出现。

1.2 方案选型:为什么用 Pandas 而不是 Excel 或 SQL

很多初学者会问:用 Excel 的数据透视表不也能做频数统计吗?确实能,但 Excel 在处理十万行以上的数据时就明显吃力,而且无法自动化。SQL 里的GROUP BY也能做,但如果你后续要用 Python 画图、建模,数据还得导回 Pandas。所以用 Pandas 一条链路走完是最顺滑的方案。

说回技术选型背后的逻辑。groupby本质上对应的是 SQL 里的GROUP BY和 Excel 里的数据透视表,它的核心思想是“split-apply-combine”三步曲:先按某个键把数据拆成若干组,再对每个组独立施加某个操作,最后把结果拼装回一张表。agg则是这个“施加操作”的灵活入口,它可以同时执行多个聚合函数,比如既能算均值又能算标准差。round解决的问题更务实——浮点数精度会让你的表格出现一长串小数尾巴,影响阅读、影响写入数据库、甚至影响后续计算的稳定性。

这三个函数组合起来,就是一个完整的“分组—聚合—结果整理”工作流。下面我会分别拆开讲透每个函数的细节。

2. groupby 分组与 agg 聚合的核心细节

2.1 groupby 的本质:拆分、应用、合并

groupby是理解 Pandas 聚合逻辑的钥匙。它的设计思想,你可以想象成小时候分水果糖:有一大袋混合口味的糖果(数据表),你需要按颜色分类(分组键),把红色的一堆、绿色的一堆分开,然后分别数每堆有几颗(聚合操作)。

在 Pandas 里,最基础的用法是df.groupby("列名")。分组之后你并不会直接看到结果,而是得到一个DataFrameGroupBy对象,它更像是一个“待执行的计划”。直到你在这个对象上调用mean()、sum()、count()之类的方法,计算才会真正发生。

这里有一个初学者特别容易犯糊涂的地方:分组键不一定要是数据表里已有的列。你可以传入一个和 DataFrame 等长的数组或 Series,Pandas 会按照这个数组的取值来切片。举个例子,你的数据表里只有一个“年龄”列,你想按年龄段分组(18-25、26-35、36-45 这种),那就先得用pd.cut()把年龄切成区间,再把这个新区间列作为groupby的键。这种用法在实际业务中比“直接按已有列分组”更常见,因为业务分析永远是按人为定义的区间来切的。

另一个容易忽略的细节是groupby后的索引。默认情况下,分组键会变成结果的索引,而不是一列普通数据。这在你后续用reset_index()做数据透视或者关联时影响很大,建议刚上手的朋友养成看完结果先reset_index()的习惯。

2.2 agg 的灵活用法:一次算多个指标

agg,全称是 aggregate(聚合),它的强大之处在于你可以一次性传入多个函数。我自己最常用的方式是传一个字典,明确指定“哪一列用什么方法算”,比如:

result = df.groupby("category").agg({ "price": ["mean", "max", "min"], "quantity": "sum", "order_id": "count" })

这段代码表达的意思是:按category分组,对price列分别算平均、最大、最小,对quantity列求和,对order_id列计数。相当于一口气完成了多张透视表的活。

需要注意的是,agg的括号里可以放什么?三种东西:字符串形式的函数名(比如"mean"、"sum"、"count")、lambda 表达式(比如lambda x: x.max() - x.min())、以及自定义函数名。第一种最常用,因为性能最好;第二种灵活但效率稍低;第三种适合特别复杂的业务逻辑。

这里有个性能上的经验之谈:如果数据量在百万行级别,尽量避免用 lambda 做复杂计算,因为 Python 的函数调用开销会被放大。能用内置函数名解决的就用内置函数名,实在不行再自定义函数。

2.3 round 的作用与隐藏坑点

round看起来最简单,就是控制小数位数,比如round(2)保留两位小数。但它有几个容易踩的坑:

第一,round在 Pandas 和 Python 原生的round()函数之间行为并不完全一致。Pandas 的round是按列或按整个 DataFrame 来作用,而 Python 原生的round()作用在单个浮点数上。如果你对一列数据操作,请使用df["列名"].round(2)而不是round(df["列名"], 2),后者会直接报错。

第二,round采用的是“银行家舍入”(round half to even),也就是说当小数位正好是 5 的时候,它不一定四舍五入,而是舍入到最近的偶数。举个极端例子:round(2.5)在 Python 里返回的是 2 而不是 3。绝大多数业务场景里这个差别微乎其微,但如果你的数据恰好大量落在“正好一半”的位置上,结果会和你预期的有偏差。

第三,round只是改变了显示精度,并没有改变底层存储的数据类型。如果你需要真正的精度控制——比如算金额——建议后续配合Decimal或者把结果乘以 100 转成整数处理。在频数分析这种场景里,我们一般只需要对比例、百分比做round(2),这就已经足够了。

3. 实操过程:从原始数据到频率分布直方图

3.1 构造模拟数据与数据清洗

先造一份模拟数据来做演示。假设我们手头有一份某电商平台一周内的客户订单数据,包含客户 ID、订单金额和商品类别三个字段。我用随机数生成 2000 条订单记录:

import pandas as pd import numpy as np np.random.seed(42) n = 2000 df = pd.DataFrame({ "customer_id": np.arange(1, n + 1), "amount": np.random.lognormal(mean=4, sigma=0.8, size=n), "category": np.random.choice(["电子", "服装", "食品", "家居"], size=n, p=[0.3, 0.3, 0.25, 0.15]) }) # 看一眼前几行 print(df.head())

为什么用lognormal而不是均匀分布?因为真实的订单金额几乎是不会均匀分布的——大部分订单集中在小额区间,少数大额订单拖出长长的尾巴。这种数据形态最能体现频率分布直方图的价值。

拿到数据后第一步是检查有没有缺失值和极端异常值。lognormal分布天然会产生一些比较大的值,所以我们需要看一看数据的整体描述:

print(df["amount"].describe())

如果发现最大值明显超出正常业务范围(比如单笔订单金额是平均值的几百倍),就需要根据业务逻辑决定是剔除还是单独处理。在这个模拟数据里,我特意让数据保持干净,以便聚焦在分组聚合的操作上,但真实场景里任何一步稀疏都可能改变你的后续结果。

3.2 使用 pd.cut 划分区间并做频率统计

现在进入核心环节。要把连续型的amount字段变成直方图,得先把数据切进若干个区间。这里我用pd.cut()来决定区间边界:

# 定义区间边界 bins = [0, 50, 100, 200, 500, 1000, np.inf] labels = ["0-50", "50-100", "100-200", "200-500", "500-1000", "1000+"] df["amount_bin"] = pd.cut(df["amount"], bins=bins, labels=labels, right=False)

这段代码的含义是:把金额划分为 6 个左闭右开的区间,right=False表示区间是[下限, 上限)的形式。这里需要强调的是,bins列表必须单调递增,否则pd.cut()会直接报错。labels列表的长度必须比bins少 1,这是一个超级常见的报错点。

接下来就是重头戏——用groupby配合agg做频率统计:

freq_stats = df.groupby("amount_bin", observed=False).agg( order_count=("customer_id", "count"), total_amount=("amount", "sum"), avg_amount=("amount", "mean") ).reset_index() # 计算频率和百分比 total_orders = len(df) freq_stats["frequency"] = freq_stats["order_count"] / total_orders freq_stats["percent"] = (freq_stats["frequency"] * 100).round(2) print(freq_stats)

这一步做完,你已经得到了一张标准的频率分布表。我再解释一下这段代码里的嵌套写法:order_count=("customer_id", "count")是 Pandas 较新版本推荐的命名聚合语法,意思是“新列名叫 order_count,取值来自 customer_id 列,使用 count 聚合”。

observed=False这个参数在category类型数据上比较重要——默认情况下groupby只会显示实际出现过的分组,而observed=False会把所有可能的分类都列出来,即使某些区间一条记录都没有,也会显示频数为 0,这对于后续对比分析非常有帮助。

3.3 频率分布直方图的两种可视化作法

计算出频数表之后,你可以有两种画图方式。第一种是直接用 Matplotlib 基于原始数值画直方图:

import matplotlib.pyplot as plt plt.figure(figsize=(10, 6)) plt.hist(df["amount"], bins=bins, edgecolor="black", alpha=0.7) plt.title("订单金额频率分布直方图") plt.xlabel("订单金额") plt.ylabel("频数") plt.show()

这种方式的好处是绘图过程由 Matplotlib 内部自动完成区间切分和频数统计,代码简洁;坏处是你无法精确控制“区间边界”以外的统计口径,也无法把频数和金额合计、占比等信息放在一张表里输出。

第二种方式是基于我们上面算好的freq_stats表直接画柱状图。这本质上是把频数统计和可视化两件事解耦了:

plt.figure(figsize=(10, 6)) plt.bar(freq_stats["amount_bin"].astype(str), freq_stats["order_count"], edgecolor="black") plt.title("各金额区间的订单量分布") plt.xlabel("金额区间") plt.ylabel("订单数") plt.show()

第二种方式更适合“要交付成果”的场合——因为你可以顺手把表导出成 Excel 给业务方,图作为附件一起展示。而且从编程习惯上看,先统计数据再画图,逻辑更清晰,调试也更容易。

3.4 分组对比:多个维度的频率分布对比

很多时候我们不止要看整体分布,还要对比不同类别的分布差异。比如,电子类和服装类的订单金额分布是否一致?这决定了定价策略、选品逻辑完全不同的运营方案。

做法其实很自然:先按类别分组,再在每个组内部做金额区间切分。我把两步合并成一段链式代码:

df_grouped = df.groupby(["category", "amount_bin"], observed=False).agg( order_count=("customer_id", "count") ).reset_index() # 计算每个类别内部的分布占比 df_grouped["category_total"] = df_grouped.groupby("category")["order_count"].transform("sum") df_grouped["percent_in_category"] = (df_grouped["order_count"] / df_grouped["category_total"] * 100).round(2) print(df_grouped.head(10))

这里出现了一个新函数transform,它和agg的区别是:agg返回的是“一行对应一个分组”的压缩结果,而transform返回的是“每一行都保留、把聚合结果广播回去”的等长结果。这样做的目的是为了在同一行里同时看到“当前分组的频数”和“这个分组所属类别的总订单数”,从而算比例。

这种“双分组键 + transform 计算占比”的模式在业务分析中极其常见——不只是金额区间,换成地区、时段、渠道都是一样的思路。你只要把category和amount_bin换成你自己的字段名,这段代码就能直接复用到很多场景。

4. 分组聚合的进阶应用与性能优化

4.1 groupby 后 groupby:多层分组如何优雅操作

有些读者可能会问:在上面 3.4 节里,我已经用groupby(["category", "amount_bin"])做了两次分组,结果已经带有多级索引了,为什么还要再groupby("category")一次?

这里其实涉及“按结果再分组”的常见需求。第一次分组得到的是每个“类别 + 金额区间”组合的明细频数;第二次分组是想在每个类别内部算一次总和,然后用这个总和去归一化比例。如果没有transform,你需要先groupby("category").agg(sum)得到一张总表,再回头用merge关联回明细表。transform把这个过程压缩成一步,代码更短、不容易出错。

如果你的分析维度更多,比如还要按“城市 + 类别 + 金额区间”三层分组,方法完全一样,把分组键列表加长即可:

df_grouped_l3 = df.groupby(["city", "category", "amount_bin"], observed=False).agg( order_count=("customer_id", "count") ).reset_index()

做这种多层分组时,要注意结果的行数会快速增长:每多一个分组键,组合数可能翻几倍。如果你的原始数据只有几千行,三层分组后有些组合可能一条记录都没有。此时observed=False会把所有组合都展示出来,行数可能失控;如果不想看见空组合,用默认的observed=True即可。

4.2 大数据量下的分组聚合性能和内存优化

模拟数据只有 2000 行,跑什么都很轻松。但真实业务场景中,几百万行甚至上亿行的数据并不少见。这时候,几个细节就变得生死攸关。

第一个建议是:在 groupby 之前先把列的类型降下来。比如把float64转成float32,把object类型的分类列转成category类型。groupby对category的支持极好,分组速度显著提升,而且省内存。

df["category"] = df["category"].astype("category") df["amount"] = df["amount"].astype("float32")

第二个建议是:避免过早把结果转成 Python 原生对象。比如不要在agg里用list()函数收集所有原始值,这会把内存打爆。如果需要收集值,考虑用tuple或者"unique"。

第三个建议是:groupby之后尽量用内置聚合函数,而不是自定义 lambda。内置函数在 C 层面运行,速度远快于 Python 层的循环。如果你确实需要做复杂的自定义聚合,考虑先用groupby.apply()调试出正确逻辑,再考虑用numba加速,这是后话。

第四个建议是:如果你的数据按某个键预先排好序,可以用groupby(..., sort=False)。默认groupby会对分组键排序,这样输出更整齐,但排序在大数据量下是有成本的。如果你后面并不需要排序后的结果,直接关闭排序能节省不少时间。

4.3 从宽表到长表:什么时候需要 melt 配合 groupby

有一种场景经常被忽略:数据是“宽表”,每一列是一个不同指标,但你要按行做定量分析。比如你的表是一周七天每天一个销售额列,你想统计“周一到周日销售额”的分布,这时候需要先把宽表melt成长表,再groupby星期几做聚合。

df_wide = pd.DataFrame({ "customer_id": [1, 2, 3, 4], "mon_amount": [100, 200, 150, 300], "tue_amount": [120, 180, 130, 250], "wed_amount": [140, 210, 170, 280] }) df_long = df_wide.melt(id_vars=["customer_id"], value_vars=["mon_amount", "tue_amount", "wed_amount"], var_name="weekday", value_name="amount") df_long["weekday"] = df_long["weekday"].str.replace("_amount", "") df_weekday = df_long.groupby("weekday").agg( total_sales=("amount", "sum"), avg_sales=("amount", "mean") ).reset_index() print(df_weekday)

melt的作用是把多列“融化”成两列:一列是原来的列名,一列是列名对应的值。这种长表格式才是groupby最喜欢的输入格式。做这种转换时特别容易犯的错误是value_vars里漏列或顺序写错,建议直接不写value_vars,让melt自动选取所有非id_vars的列。

5. 实战中常见的坑与处理技巧

5.1 groupby 的结果到底是不是 DataFrame

很多初学者会在这里被绕晕:df.groupby("列名").agg({"金额": "sum"})返回的结果确实是一个 DataFrame,但它的索引是分组键。想把它当成普通表格处理,就得用reset_index()把索引变成列。不少人忘了这一步,后续做merge或to_csv时会发现多了一列index或莫名其妙丢了一列。

有一个细节值得单独说:当groupby后面跟了多个聚合函数时,返回的列名会变成多重索引(MultiIndex),格式类似("金额", "sum")。如果你用reset_index(),列名会变成("金额", "sum")这样的元组形式,处理起来非常别扭。我的习惯是尽早用命名聚合(如total_amount=("amount", "sum"))把列名展平,避免后续在列名上踩坑。

5.2 agg 和 apply 的区别:什么时候用哪个

这是我在教学过程中被问得最多的问题之一。简单说:agg的每个函数都接收一个 Series 作为输入,返回一个标量,输出行数与分组数相同,适合“一组一行”的统计结果;apply则更自由,它接收一个 DataFrame 作为输入(如果你直接df.groupby(...).apply(func)),可以返回任意形状的数据——比如一个分组内生成一个小表格。

实际业务经验是:能用agg的绝不用apply。apply灵活,但灵活意味着难以优化、难以预测输出结构。只有当你需要“每个分组内部做排序后取前 N 行”这类复杂操作时,才用apply或者别的专用方法如head()、nlargest()。

5.3 频率分布直方图的可视化细节

前面主要聚焦在用 Pandas 计算频率表,画图是顺势而为。但有几个可视化的细节值得单独提一下:

一是bins的数量选择。不是越多越好,也不是越少越好。一般经验是按“数据的量级”来定,2000 条记录分成 6-10 个区间比较合适,如果数据到几万条,可以适当增加到 20 个左右。区间太少会掩盖分布的细节,区间太多会产生大量噪声让信息碎片化。

二是坐标轴标签的旋转问题。如果你的分组是中文文字或较长的字符串,画柱状图时标签很容易重叠。一个简单办法是用plt.xticks(rotation=45),或者直接基于freq_stats["amount_bin"].astype(str)做横轴,并配合rotation参数调整。

三是百分比信息的标注。柱状图旁边标注每个柱子的具体数值,对业务汇报非常有帮助。可以用plt.text()或者ax.bar_label()来实现。在 Matplotlib 3.4.0 以上版本,bar_label是最方便的:

ax = plt.subplot() bars = ax.bar(freq_stats["amount_bin"].astype(str), freq_stats["order_count"], edgecolor="black") ax.bar_label(bars, fmt="%d")

四是直方图和柱状图的本质区别。直方图的横轴是连续的数值区间,柱子之间没有间隙;柱状图的横轴是离散的类别,柱子之间可以有间隙。很多人把它们混为一谈,在可视化沟通时会产生歧义。严格来说,如果横轴是“金额区间”这种有顺序的连续分段,用直方图风格(无间隙)更贴近数据语义。

6. 常见问题速查与经验总结

这里整理一份我在实际项目里反复遇到、也经常被身边同事问到的排查清单:

表格形式可能更直观:

问题现象可能原因解决办法
groupby后列名变成了元组使用了多聚合函数,列名形成 MultiIndex改成命名聚合写法,或reset_index()后重命名
pd.cut()报“bins must increase monotonically”bins列表不是严格递增检查区间边界是否单调上升,去掉重复值
round(2)结果与预期差 0.01浮点数二进制表示误差或银行家舍入使用round()前先加一个极小偏移量,或改用Decimal
groupby结果中少了某些分组分组键是浮点或字符串时存在缺失值先dropna()或用fillna()填充缺失值再分组
agg里的 lambda 运算特别慢Python 层函数调用开销大替换为内置函数名,或提高数据筛选率后再聚合
数据透视后行数比你预期多很多observed=False把全组合均列出改为observed=True,过滤空分组
画柱状图时横轴标签重叠分组名过长或过多用plt.xticks(rotation=45)或缩短标签
DataFrame 输出时出现index列to_csv()默认带索引写文件时加index=False

6.1 一个完整练习:动手复现整套流程

纸上得来终觉浅,建议你拿到这篇文章后,自己动手复现一遍完整流程:

  1. 造一份包含“用户 ID、注册时长(天)、付费金额(元)、渠道来源(四个渠道之一)”的模拟数据,样本量在 3000 左右。
  2. 用pd.cut()把注册时长按 0-30、30-90、90-180、180 天以上切分。
  3. 用groupby(["渠道来源", "注册时长区间"]).agg()分别统计每个组合的用户数、平均付费金额、总付费金额。
  4. 利用transform计算每个渠道内部不同时长区间的付费占比,并按占比排序。
  5. 用round控制小数位,导出表格和柱状图。

这个过程走完,你对groupby、agg、round的理解会从“知道函数”升级为“能在真实场景中自如组合使用”。

6.2 最后一个实用小细节

分享一个我自己的使用习惯:在做频数分布时,建议顺便把两个描述性统计的指标一起算出来——累计频率和累计百分比。它们对业务决策非常有用,比如“前 20% 的用户贡献了多少收入”这句话背后就需要累计占比。操作上是基于频数表再做一次cumsum():

freq_stats["cumulative_count"] = freq_stats["order_count"].cumsum() freq_stats["cumulative_percent"] = (freq_stats["cumulative_count"] / total_orders * 100).round(2)

cumsum是groupby和agg的绝佳搭档,尤其在帕累托分析、出货量分级、库存区间统计中经常用到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询