Pandas分组聚合实战:用groupby、agg、round绘制频率分布直方图 做数据分析时间长了你会发现很多刚入门的朋友一上来就研究各种高级模型、复杂算法结果拿到一份实际业务数据反而手足无措。反倒是那些最基础的操作——分组、聚合、算频率、画分布——才是日常工作中最高频使用的技能。今天借“定量分析—频率分布直方图”这个话题把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 等长的数组或 SeriesPandas 会按照这个数组的取值来切片。举个例子你的数据表里只有一个“年龄”列你想按年龄段分组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(mean4, sigma0.8, sizen), category: np.random.choice([电子, 服装, 食品, 家居], sizen, 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], binsbins, labelslabels, rightFalse)这段代码的含义是把金额划分为 6 个左闭右开的区间rightFalse表示区间是[下限, 上限)的形式。这里需要强调的是bins列表必须单调递增否则pd.cut()会直接报错。labels列表的长度必须比bins少 1这是一个超级常见的报错点。接下来就是重头戏——用groupby配合agg做频率统计freq_stats df.groupby(amount_bin, observedFalse).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 聚合”。observedFalse这个参数在category类型数据上比较重要——默认情况下groupby只会显示实际出现过的分组而observedFalse会把所有可能的分类都列出来即使某些区间一条记录都没有也会显示频数为 0这对于后续对比分析非常有帮助。3.3 频率分布直方图的两种可视化作法计算出频数表之后你可以有两种画图方式。第一种是直接用 Matplotlib 基于原始数值画直方图import matplotlib.pyplot as plt plt.figure(figsize(10, 6)) plt.hist(df[amount], binsbins, edgecolorblack, alpha0.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], edgecolorblack) plt.title(各金额区间的订单量分布) plt.xlabel(金额区间) plt.ylabel(订单数) plt.show()第二种方式更适合“要交付成果”的场合——因为你可以顺手把表导出成 Excel 给业务方图作为附件一起展示。而且从编程习惯上看先统计数据再画图逻辑更清晰调试也更容易。3.4 分组对比多个维度的频率分布对比很多时候我们不止要看整体分布还要对比不同类别的分布差异。比如电子类和服装类的订单金额分布是否一致这决定了定价策略、选品逻辑完全不同的运营方案。做法其实很自然先按类别分组再在每个组内部做金额区间切分。我把两步合并成一段链式代码df_grouped df.groupby([category, amount_bin], observedFalse).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], observedFalse).agg( order_count(customer_id, count) ).reset_index()做这种多层分组时要注意结果的行数会快速增长每多一个分组键组合数可能翻几倍。如果你的原始数据只有几千行三层分组后有些组合可能一条记录都没有。此时observedFalse会把所有组合都展示出来行数可能失控如果不想看见空组合用默认的observedTrue即可。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(..., sortFalse)。默认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_nameweekday, value_nameamount) 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(rotation45)或者直接基于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], edgecolorblack) ax.bar_label(bars, fmt%d)四是直方图和柱状图的本质区别。直方图的横轴是连续的数值区间柱子之间没有间隙柱状图的横轴是离散的类别柱子之间可以有间隙。很多人把它们混为一谈在可视化沟通时会产生歧义。严格来说如果横轴是“金额区间”这种有顺序的连续分段用直方图风格无间隙更贴近数据语义。6. 常见问题速查与经验总结这里整理一份我在实际项目里反复遇到、也经常被身边同事问到的排查清单表格形式可能更直观问题现象可能原因解决办法groupby后列名变成了元组使用了多聚合函数列名形成 MultiIndex改成命名聚合写法或reset_index()后重命名pd.cut()报“bins must increase monotonically”bins列表不是严格递增检查区间边界是否单调上升去掉重复值round(2)结果与预期差 0.01浮点数二进制表示误差或银行家舍入使用round()前先加一个极小偏移量或改用Decimalgroupby结果中少了某些分组分组键是浮点或字符串时存在缺失值先dropna()或用fillna()填充缺失值再分组agg里的 lambda 运算特别慢Python 层函数调用开销大替换为内置函数名或提高数据筛选率后再聚合数据透视后行数比你预期多很多observedFalse把全组合均列出改为observedTrue过滤空分组画柱状图时横轴标签重叠分组名过长或过多用plt.xticks(rotation45)或缩短标签DataFrame 输出时出现index列to_csv()默认带索引写文件时加indexFalse6.1 一个完整练习动手复现整套流程纸上得来终觉浅建议你拿到这篇文章后自己动手复现一遍完整流程造一份包含“用户 ID、注册时长天、付费金额元、渠道来源四个渠道之一”的模拟数据样本量在 3000 左右。用pd.cut()把注册时长按 0-30、30-90、90-180、180 天以上切分。用groupby([渠道来源, 注册时长区间]).agg()分别统计每个组合的用户数、平均付费金额、总付费金额。利用transform计算每个渠道内部不同时长区间的付费占比并按占比排序。用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的绝佳搭档尤其在帕累托分析、出货量分级、库存区间统计中经常用到。