资讯动态

Pandas数据合并与分组聚合实战:concat、merge、join、groupby详解

发布时间:2026/10/21 22:43:04 来源:尧图企业网站定制
从日常取数做报表的场景说起不管是哪个行业的分析岗拿到手的数据很少是“一张表搞定一切”的。订单、商品、用户、渠道往往各占一张表要算经营指标第一步是把它们按逻辑关联起来第二步是按维度分组汇总。放到 Pandas 里就是 DataFrame 的合并与分组聚合。这篇就当是数据分析系列的实战笔记重点讲清concat、merge、join、groupby这几个功能的边界和用法顺便把我踩过的坑一并交代清楚。1. 从报表需求看合并与分组聚合的实际位置1.1 一个电商分析场景是怎么把这套工具串起来的假设你现在面对的分析场景某电商平台有每日订单明细分散在 1 月到 6 月 6 张 CSV 里另有一张商品维度表记录每个商品的类目、成本、上架日期。老板要的报表是“每个月、每个一级类目的销售额、订单数、客单价”。这个需求拆开看就两步先把 6 个月的订单明细纵向堆到一起再把订单表和商品表横向关联然后按月、类目做分组聚合。这就是 Pandas 合并和分组聚合最典型的使用场景也是我在上一篇笔记里预告过的内容。不少初学者容易一上来就盯着“怎么写代码”其实更该先想明白数据加工的方向。纵向堆叠用pd.concat横向关联用pd.merge分组汇总用groupby。这三个功能互相之间不能替代搞混了轻则结果出错重则报表对不上数。1.2 合并和分组聚合要解决的核心问题先给这套工具做个定位合并解决的是“多张表怎么按行或按列组合到一起”的问题分组聚合解决的是“明细数据怎么按维度压缩成汇总数据”的问题。二者经常连在一起用比如先合并出宽表再分组算出指标。Excel 里的VLOOKUP和透视表到 Python 里对应的就是merge和groupby。如果你过去用过 Excel 做报表这套迁移理解起来非常快。VLOOKUP是按一个关键字段把另一张表的列匹配进来Pandas 里的merge做的也是同一件事只不过能力更强、控制更细透视表的行标签、列标签、值字段对应groupby里的分组键、聚合函数和数值列。2. 三种合并方式的边界什么时候该用 merge 而不是 concat2.1 pd.concat纵向堆多张表别和 merge 搞混pd.concat最常用的场景是纵向堆叠多个结构相同的 DataFrame 按行拼起来。比如 1 月到 6 月的订单表列名都一样直接拼成一份大表。import pandas as pd df_jan pd.read_csv(orders_2024_01.csv) df_feb pd.read_csv(orders_2024_02.csv) # ... 省略中间月份 df_orders pd.concat([df_jan, df_feb, df_mar, df_apr, df_may, df_jun], ignore_indexTrue)这里ignore_indexTrue是刚入门最容易漏的参数。不设置的话合并后的 DataFrame 会保留各个子表原有的行索引最后你会得到一串重复的索引值后面一旦做索引对齐或 join 操作很容易出问题。设置之后会用连续的 0、1、2……重新生成索引清爽很多。concat还可以横向拼接也就是axis1按列方向把两张表拼在一起。但它横向拼接的逻辑是按索引对齐而不是按某个业务键匹配。如果两张表的行索引都是 0 到 99拼出来的宽表就先验地默认“第 0 行是一回事”这在大多数业务场景下是不成立的。所以我的习惯是横向拼接只在明确知道行顺序一一对应时使用否则一律走merge。2.2 pd.mergeSQL 式关联重点是键和 how 的取舍pd.merge是真正的“SQL JOIN”式操作靠一个或多个键把两张表的行匹配起来。订单表里有product_id商品表里也有product_id按它关联就能把类目、成本这些列补到订单明细上。df_merged pd.merge( df_orders, df_products, onproduct_id, howleft )how参数是这里的关键决策点我用一段话说明白inner只保留两边键都匹配上的行订单表里有但商品表没有的商品会整个消失。left以左表为准右表匹配不上的列填 NaN。推荐在“明细表为主表”的场景用因为你通常不希望订单行因为商品维度缺失而丢掉。right以右表为准用得相对少。outer两边都保留匹配不上的部分补 NaN适合对账场景。实际做业务报表时left用得最多。因为明细表是事实表每一行都代表一笔真实发生的业务维度表补不齐最多是缺失不该把事实丢掉。当两张表的键列名不一样时用left_on和right_on分别指定df_merged pd.merge( df_orders, df_products, left_onproduct_id, right_onid, howleft )合并完之后两张表里的键列如果原本就重名Pandas 会自动加上_x和_y后缀。这个设计有时候很有用比如订单表和商品表都有一列叫date合并后会得到date_x下单日期和date_y商品上架日期正好能区分。2.3 DataFrame.join索引关联的快车道df.join是建立在merge之上的快捷方法默认按索引对齐而不是按列值匹配。什么时候用到它当你在groupby之后得到一个以分组键为索引的汇总表又想把汇总指标塞回原表时join就很顺手。df_summary df_orders.groupby(product_id)[amount].sum() df_orders df_orders.join(df_summary, onproduct_id, rsuffix_total)onproduct_id表示左表用这一列的取值去匹配右表df_summary的索引。rsuffix用来给重复列名加后缀避免冲突。这个场景用join代码更简洁但我自己在正式脚本里还是倾向显式merge因为可读性更明确团队其他人接手时一眼就能看出关联逻辑。2.4 merge 后行数变多不代表出错新手第一次遇到merge后行数比原来还多第一反应往往是代码写错了。其实不对merge是可能产生行数膨胀的。如果右表的键存在重复值一个商品在维度表里出现了两行比如同一商品对应两条不同记录左表的每一笔订单就会和右表的每一行分别匹配一次造成多行。排查方法很简单dup_count df_products[product_id].duplicated().sum() print(dup_count)duplicated()返回布尔序列sum()直接算出重复次数。我把这个检查写进日常数据处理的第一步比等到报表数字异常再去回溯高效得多。3. groupby 的底层逻辑Split-Apply-Combine 三步走3.1 GroupBy 对象为什么“懒”groupby背后的思想是 Split-Apply-Combine先按分组键拆分数据再对每个分组应用计算最后把结果合并成一张新表。这个概念听起来抽象但想明白它对后续写代码帮助很大。grouped df_orders.groupby(product_id)这行代码执行完grouped是一个DataFrameGroupBy对象但它本身没有做任何实际计算。它记录的是“按什么键分组、数据在哪里”真正的计算要等你调用sum()、mean()、agg()这些方法时才会触发。这种惰性机制的好处是同一个分组对象可以反复指定不同的聚合逻辑不用重复拆分组。如果你在 Jupyter 里打印grouped看到的只是一个对象地址看不到具体数据。这就是正常的不要以为是 bug。想快速看每个分组长什么样可以用for name, group in grouped: print(name) print(group.head())3.2 agg 传参的三种形态aggaggregate 的缩写是分组后最灵活的函数入口。它可以接收多种形态的参数我按从基础到进阶的顺序列一下列名 单个函数名df_orders.groupby(product_id)[amount].agg(sum)一列多个函数df_orders.groupby(product_id)[amount].agg([sum, mean, count])不同列不同函数df_orders.groupby(product_id).agg({amount: sum, quantity: mean})多加一个函数时返回的列会自动以函数名命名可以配合rename改成业务名称result ( df_orders .groupby(product_id)[amount] .agg([sum, mean, count]) .rename(columns{sum: 销售额, mean: 平均单价, count: 订单数}) )顺便提醒一个细节count()统计的是非空值的个数如果某列的 NaN 较多count的结果会比其他列少这是正常的但如果要统计行数用size()更准确它不受 NaN 影响。3.3 多级分组后的 DataFrame 长什么样当分组键不止一个时比如按“月份 类目”分组结果是多级索引MultiIndexdf_result ( df_orders .groupby([month, category])[amount] .sum() )打印df_result时月份和类目会以层级索引的形式出现。这个结构直接用于展示没问题但如果你想把它继续 merge 到别的表或者导出给业务方最好用reset_index()把层级索引拍平成普通列df_result df_result.reset_index()拍平之后原来的分组键变成两列普通字段再配合sort_values排序就能得到一张干净整洁的明细汇总表。很多人在这一步卡住其实只需要记住一句话多级分组默认生成层级索引reset_index()是回到普通表结构的万能钥匙。4. 聚合结果回流原表transform 和 apply 怎么选4.1 transform 保持行数不变的巧劲groupby加agg的结果是“按分组键压缩成若干行”但如果我想在原表基础上增加一列比如“每个商品类目的总销售额”要求这一列的值在每个类目下所有行重复出现这时候agg就不好使了。它把数据压缩了行数对不上。transform专门解决这个问题。它返回的是和原表行数一致的结果相当于把每个分组的聚合值广播回原表的每一行df_orders[类目总销售额] ( df_orders .groupby(category)[amount] .transform(sum) )跑完这行代码同一类目下的每一笔订单都会带上该类目的总销售额。这个字段有什么用计算占比特别方便df_orders[类目占比] df_orders[amount] / df_orders[类目总销售额]一次就能算出每个商品在所属类目中的销售贡献占比不用先聚合再 merge 回去。这个用法我在做品类结构分析时几乎天天用到强烈建议熟练掌握。4.2 apply 适合自定义逻辑groupby里还有apply它和agg、transform的区别在于apply把整个分组作为一个整体传入函数你可以写任意自定义逻辑返回 DataFrame、Series 甚至标量都可以。比如想对每个分组做“排序后取前两名”这类操作用apply最直白def top_two(group): return group.nlargest(2, amount) df_top ( df_orders .groupby(category, group_keysFalse) .apply(top_two) )group_keysFalse是为了不让分组键重复出现在结果索引里否则结果会变成一个多级索引结构多一层你未必需要的层级。这里得提醒一句groupby.apply性能开销相对较大能写成分步操作的尽量写成分步操作。比如“取每个类目前两名”也可以用rank加布尔筛选实现速度会更快。apply适合逻辑复杂且不易拆分的场景不要小题大做。4.3 性能上的考虑数据量小的时候这些函数的性能差异几乎可以忽略但数据量上到几十万上百万行差异就很明显了。我自己实测的经验是transform和agg走的是优化过的底层路径速度通常比apply快 3 到 5 倍。能用transform解决的不要绕路用apply能用内置聚合函数解决的不要自定义函数。粗暴但好用的性能建议 1. 分组聚合优先用 agg 内置函数sum、mean、count 2. 需要返回原表行列数的分组计算优先用 transform 3. apply 留给无法用前两者表达的业务逻辑5. 一份经营日报的完整加工链路5.1 数据结构准备与字段检查把上面所有知识点串起来做一个接近真实工作的案例。假设我有两张表orders_*.csv订单明细包含order_id、order_date、product_id、quantity、amountproducts.csv商品维度包含product_id、category、product_name、cost先读数据顺手检查类型和空值这是我每次动手前固定会做的动作df_orders pd.read_csv(orders_2024_01.csv) df_products pd.read_csv(products.csv) print(df_orders.dtypes) print(df_orders.isna().sum())我见过太多次翻车是因为日期列读进来是 object 类型后面想按月分组却发现格式不统一。所以日期转换要在最早阶段做df_orders[order_date] pd.to_datetime(df_orders[order_date]) df_orders[month] df_orders[order_date].dt.to_period(M)5.2 合并订单明细与商品维度表六张月度订单一口气concat起来然后merge商品维度表拿到类目df_all pd.concat([df_orders, df_orders2, df_orders3], ignore_indexTrue) df_all pd.merge( df_all, df_products[[product_id, category, cost]], onproduct_id, howleft )合并完成后建议立刻检查两个东西一是行数是否等于合并前的订单行数len(df_all)与len(df_orders) len(df_orders2) len(df_orders3)对比二是category列的空值数量。如果空值很多说明商品维度表覆盖不全这个缺口要在报表里标注出来别默默丢掉。5.3 按类目和月份分组聚合出核心指标接着按month和category分组同时算出销售额、订单数、客单价一步到位daily_report ( df_all .groupby([month, category]) .agg( 销售额(amount, sum), 订单数(order_id, nunique), 客单价(amount, mean) ) .reset_index() )这里用到了named agg的写法也就是.agg()里直接以关键字指定新列名。这个语法比传字典更清晰生成的列名直接就是业务名称不用二次rename。nunique用来统计唯一订单数量避免一单多商品被重复计为多单。5.4 排序、输出与结果验证分组结果出来后按月份和销售额做排序让报表从上到下读起来自然daily_report daily_report.sort_values( [month, 销售额], ascending[True, False] )导出到 Excel 时我习惯加一个汇总行或者单独 sheet。最简单的做法是存多个 sheetwith pd.ExcelWriter(daily_report.xlsx) as writer: daily_report.to_excel(writer, sheet_name月度类目报表, indexFalse) df_all.to_excel(writer, sheet_name合并明细, indexFalse)导出的报表是不是准确我通常在业务侧验证一下抽查某个月某个类目的销售额用原始明细自己筛出来加一遍对个数。数据加工链路越复杂抽样验证越不能省。这个习惯帮我发现过不少问题——基本上每次都能顺手揪出一两个维度表重复或日期格式不一致的锅。6. 实用排错思路合并完数据对不上怎么办6.1 键字段里有重复值场景merge后行数从 5000 涨到 8200明显不对。先检查键的唯一性这是第一个排查点print(df_products[product_id].duplicated().any())如果键重复需要决定保留哪个。比如商品维度表里有两个版本的成本价可以按更新时间取最新一条df_products ( df_products .sort_values(update_date) .drop_duplicates(product_id, keeplast) )keeplast保留排序后最后一条也就是最新更新的记录。这种“先排序再删重”的处理思路在很多业务表里都适用能避免先入为主的错误版本留下来。6.2 合并后 NaN 暴增场景合并完发现右侧列全是 NaN业务上这些商品明明应该存在。这类问题大概率是键类型不一致。订单表里product_id是整数 1001商品表里同一字段却读取成了字符串1001merge时严格匹配类型不同直接匹配不上。用dtypes各看一眼就知道print(df_orders[product_id].dtype) print(df_products[product_id].dtype)统一类型用astype解决df_orders[product_id] df_orders[product_id].astype(str) df_products[product_id] df_products[product_id].astype(str)我习惯统一转成字符串因为业务键经常有前导零比如001和1在整型下会莫名其妙等值转成字符串反而更能保住原有格式。6.3 groupby 后结果里莫名多出一行 NaN场景分组聚合后索引里出现一个名字是 NaN 的组业务上并没有这个分类。原因一般是分组键列里本身就含有 NaN。Pandas 会把 NaN 单独作为一个组来聚合不会报错。处理方式很简单先决定这些缺失值怎么归属再执行分组。df_orders[category] df_orders[category].fillna(未知)如果业务上说不清缺失值该放哪个类目用“未知”占位比直接丢弃数据更稳妥。丢失明细数据容易导致后续汇总口径不一致也会让看报表的人对数字缺失产生疑问。6.4 布尔筛选与 groupby 的顺序坑又一个很容易翻车的细节有的人写代码喜欢先groupby再筛选写出来没报错但结果完全不是预期。比如想统计“销售额大于 100 的订单”的分类汇总写成下面这样就错了# 错误示例先分组再筛选筛的是分组后的结果 df.groupby(category)[amount].sum()[df[amount] 100]这里df[amount] 100是布尔序列和分组后的 Series 索引对不上轻则报错重则静默得到错误结果。正确写法是先筛选再分组df_filtered df[df[amount] 100] result df_filtered.groupby(category)[amount].sum()这种顺序问题在调试时特别难发现因为报错信息不一定明显。我给自己的提醒是数据处理管线里筛选永远在聚合之前。最后再分享一条经验。学了合并和分组聚合之后很多同学容易进入一个误区能用 Pandas 实现的功能恨不得全用 Pandas 做。但实际工作中如果数据量大到内存吃紧或者关联逻辑复杂到十几个表连环merge我更倾向于先把逻辑拆开写清楚每一步都做一次行数和空值验证。数据加工不怕写得慢就怕错了不知道。把每一步的结果用shape和isna().sum()盯住合并错了、分组丢了都能尽早暴露。这套“边加工、边验证”的思路比记住任何 API 都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑