Pandas合并与分组聚合详解:从concat、merge到groupby+agg实战
数据分析的硬骨头其实就这两块很多人学 Pandas前面读写数据、清洗数据都挺顺一到了合并和分组聚合就开始卡壳。原因很简单这两块内容不像dropna()、fillna()那样单打独斗它们需要你脑子里同时装着一套表与表之间的关系和数据从宽到长的变化逻辑。尤其是分组聚合它几乎是所有日常报表、统计分析的底座——不管你是做电商订单分析、用户行为留存、还是财务对账归根到底都是在做先分组再聚合这件事。这篇文章我打算把 Pandas 里最常用的两个操作讲透DataFrame 的合并concat、merge和分组聚合groupbyagg并且带着真实场景走一遍完整流程。不只是告诉你函数怎么用我会把每个操作背后的适用场景、参数选择的原因、以及我实际踩过的坑也都写出来。适合谁看已经学过 Pandas 基础操作、想进阶到能用数据回答业务问题阶段的人以及做数据分析时总觉得自己代码写得很啰嗦、想用更优雅方式搞定问题的人。1. 合并操作的两种思路堆叠与连接1.1 concat纵向堆叠与横向拼接concat的全名叫pd.concat()它做的事情非常简单粗暴把两个或多个 DataFrame 直接拼在一起。拼的方向由axis参数决定axis0是纵向堆叠默认axis1是横向拼接。纵向堆叠最常见的场景是你手里有几张结构完全一样的表比如一月份销售数据、二月份销售数据、三月份销售数据需要合并成一张季度表。这时候用pd.concat([df1, df2, df3])即可前提是这几张表的列名必须保持一致否则 Pandas 会按列名对齐缺失位置填 NaN。横向拼接相对少用但也偶尔会碰到比如某个 DataFrame 存了用户的基础信息另一个存了用户当月的消费汇总索引都是用户ID这时候axis1可以快速把两张表拼在一起。实际操作中最容易翻车的点是索引。concat默认保留原索引如果原表里有重复索引拼接后会出现大量重复的索引值这时候后续做loc选取或groupby可能被干扰。建议在拼接后执行df.reset_index(dropTrue)重新生成一份干净的自增整数索引。import pandas as pd q1 pd.DataFrame({月份: [1月, 2月, 3月], 销售额: [12000, 15000, 17000]}) q2 pd.DataFrame({月份: [4月, 5月, 6月], 销售额: [19000, 21000, 23000]}) # 纵向堆叠 half_year pd.concat([q1, q2], axis0, ignore_indexTrue) print(half_year)上面代码里我加了ignore_indexTrue这样拼接后索引会重置为 0 到 5省去手动 reset 的麻烦。如果你忘了加也能事后补救但养成秒加这个参数的习惯会省不少事。1.2 mergeSQL 风格的连接操作merge才是真正意义上的合并它解决的是两张表通过某个共同字段关联起来的问题对应 SQL 里的JOIN。核心参数有四个how、on、left_on/right_on、suffixes。how的可选值包括inner内连接只保留两边都匹配上的行、left左连接以左表为基准、right右连接以右表为基准、outer外连接取并集。四个连接方式对应业务上不同的需求实际分析里left连接用得最多因为它最保险——不会因为右表有额外记录导致左表行数膨胀也不会丢失左表主体记录。on参数指定两个表共同拥有的连接键。如果两张表的字段名不同比如左表叫user_id右表叫uid就必须用left_on和right_on分开指定。连接键重复是很危险的如果右表的连接键有重复值连接后行数会翻倍这在统计里叫多对多连接使用前务必确认连接键的唯一性。orders pd.DataFrame({ 订单号: [A001, A002, A003], 用户ID: [U1, U2, U3], 金额: [199, 299, 499] }) users pd.DataFrame({ 用户ID: [U1, U2, U4], 城市: [北京, 上海, 广州] }) # 左连接以订单表为主补上用户城市 merged orders.merge(users, on用户ID, howleft) print(merged)这段代码的运行结果中A003 订单因为用户 U3 在用户表里不存在城市字段会是 NaN。这就是左连接的典型场景——订单数据是全的用户资料可能不全但你不希望订单记录被删掉。1.3 连接键的陷阱重复、缺失与类型不一致连接键看着简单实际上是合并操作的最大坑位。我处理过一个很典型的 case两张表的用户ID一个是整数类型int64另一个是字符串类型objectmerge直接报错或者匹配不到任何记录。Pandas 不会自动帮你做类型转换你需要手动统一df1[用户ID] df1[用户ID].astype(str) df2[用户ID] df2[用户ID].astype(str)另外连接键里如果有重复值一定要注意行数变化。当右表连接键重复时左表的对应行会被复制多份。如果你只想知道某个用户有没有下单而右表每个用户下了 5 单连接后左表同样一行会变 5 行后面做去重或统计时容易懵。2. 分组聚合从明细数据到统计视图2.1 groupby 到底在做什么理解groupby的核心是把它想成一个拆-算-合三步走的过程。拆按某个或某几个字段把大表拆成多个小组算对每个小组独立执行聚合计算求和、平均、计数等合把每个小组的计算结果合并成一张汇总表。整个过程听起来简单但写代码的时候容易犯迷糊因为 Pandas 的语法把这几个步骤压成了一行。df.groupby(城市)[销售额].sum()这一行拆开来看groupby(城市)是按城市分组[销售额]是告诉 Pandas 只关心销售额这一列.sum()是对每个城市的销售额求和。返回的结果是一个 Series城市名作为索引。如果想同时对多列做不同的聚合计算用.agg()方法。它接收一个字典键是列名值是对应的聚合函数名或函数对象result df.groupby(城市).agg({ 销售额: [sum, mean], 订单量: count, 利润: max })这时候返回的是多层次列索引的 DataFrame第一层是原始列名第二层是聚合函数名。拿到这种结果后通常需要把列索引降下来result.columns [_.join(col).strip(_) for col in result.columns]或者用result.reset_index()把分组字段变回普通列。2.2 常用聚合函数与自定义聚合Pandas 内置的聚合函数数量不少日常用到的主要有这些函数作用适用场景sum()求和销售额、订单金额汇总mean()平均值客单价、平均库存median()中位数对异常值敏感的数据count()非空计数订单数、活跃用户数nunique()去重计数独立的用户数、品牌数max()/min()最大/最小值最高单笔金额、最早下单时间std()/var()标准差/方差波动性分析count和nunique的区别经常有人混淆。count统计的是这一列非空值的数量如果某条记录该字段是 NaN 就不会被算进去nunique统计的是去重后的数量。举例来说统计订单表里有多少个用户要用df[用户ID].nunique()统计订单表里有多少条成交订单要用df[订单号].count()。除了内置函数.agg()也支持传入自定义函数。自定义函数接收一个 Series或者 DataFrame取决于调用方式返回一个标量值def price_range(s): return s.max() - s.min() result df.groupby(城市)[销售额].agg([sum, price_range])注意用自定义函数时聚合结果列名就是函数名如果想要更易读的名字可以用 lambda 配合自定义函数封装或者聚合后重命名列。2.3 多级分组分组键的排列组合实际业务里很少只有一个分组维度更多是按城市按品类这种交叉分析。groupby直接接收一个列表就可以做多级分组result df.groupby([城市, 品类]).agg({销售额: sum}).reset_index()返回结果的行顺序是由外层分组键城市主排序的然后才是内层品类。如果希望显示上更清晰可以加sortTrue参数做组内排序。多级分组之后如果不想 reset_index得到的是一个 MultiIndex 索引的 DataFrame很多新手在这里犯迷糊——明明数据都对但透视表转不过来。我的建议是只要后续还要做数据处理就得reset_index()只有最终展示时才考虑保留 MultiIndex 方便看层级关系。多级分组还有一个隐藏用法就是分组键本身可以是多列的组合且不一定是原始列名——你可以在分组前先构造新列比如把时间字段裁剪成年月再按新列分组。这个技巧在时间序列分析里用得尤其多。3. 实战案例订单数据的合并分析与分组统计3.1 场景定义与数据准备我模拟一个偏实战的场景来把上面的知识串起来。假设你手上有一份电商平台的订单明细表orders.csv字段包括订单号、用户ID、下单时间、城市、品类、金额、利润。另外还有一份用户等级表users.csv字段包括用户ID、注册时间、会员等级。需求是计算每个城市、每个会员等级的总销售额和总利润并找出每个城市销售额最高的品类。先用代码生成模拟数据方便你跟着跑import pandas as pd import numpy as np np.random.seed(42) city_list [北京, 上海, 广州, 深圳, 杭州] cat_list [数码, 服饰, 食品, 家居, 美妆] level_list [普通会员, 白银会员, 黄金会员, 铂金会员] orders pd.DataFrame({ 订单号: [fO{i:05d} for i in range(1000)], 用户ID: np.random.choice([fU{i} for i in range(300)], size1000), 城市: np.random.choice(city_list, size1000), 品类: np.random.choice(cat_list, size1000), 金额: np.random.randint(50, 1000, size1000), 利润: np.random.randint(5, 200, size1000), }) users pd.DataFrame({ 用户ID: [fU{i} for i in range(300)], 会员等级: np.random.choice(level_list, size300), })这里orders表有 1000 行订单的用户来自 300 个用户users表是用户基础信息。两张表通过用户ID关联。3.2 合并订单表与用户表先进行左连接补齐每个订单对应的会员等级df orders.merge(users, on用户ID, howleft) print(df.shape) # 检查行数是否还是1000 print(df[会员等级].isna().sum()) # 查看缺失情况因为users表包含了所有订单里出现的用户理论上缺失值为 0。但如果你把users表换成从某个渠道导出的部分用户缺失值就出现了。我在项目里遇到过一次某个数据源的用户ID是历史遗留下来的旧格式订单表里是新的统一格式merge 之后几千行订单匹配不上用户等级。这种问题排查起来很头疼因为你看到的是大量 NaN第一反应是脏数据实际上是对接键出了问题。所以做 merge 时建议养成一个习惯先验证连接键的类型和样例值再用isna()统计连接后的缺失情况。如果左连接后关键字段的缺失率特别高大概率不是数据质量问题而是连接键本身没对齐。3.3 分城市统计销售额与利润现在我们有了包含会员等级的完整订单表接下来按城市进行分组聚合city_stats df.groupby(城市).agg({ 金额: [sum, mean, count], 利润: sum }).reset_index() # 展开多层列索引 city_stats.columns [_.join(c).strip(_) for c in city_stats.columns] print(city_stats)运行后你会得到一个 5 行 × 5 列的统计表包含每个城市的订单总金额、平均订单金额、订单数量和总利润。这种表拿出来可以直接用 Excel 打开或者接入后续的可视化流程。这里我用了_.join(c).strip(_)把多层列索引展平。为什么需要这一步因为 Pandas 生成的多层列索引在后续处理中非常别扭比如你想改成中文列名、按某列排序、或者直接传给绘图库多级索引都会造成不必要的麻烦。展平成普通字符串列名是约定俗成的做法。3.4 城市 会员等级交叉分析继续推进我们现在需要看每个城市内部不同会员等级的消费贡献。这个分析的价值很直接判断哪些城市的高等级会员贡献占比更大从而指导运营资源投放。cross_stats df.groupby([城市, 会员等级]).agg({ 金额: [sum, count], 利润: sum }).reset_index() cross_stats.columns [_.join(c).strip(_) for c in cross_stats.columns] # 按城市分组内部按金额排序 cross_stats_sorted cross_stats.sort_values([城市, 金额_sum], ascending[True, False]) print(cross_stats_sorted.head(10))多级分组之后先展平列索引再按组内排序是最顺畅的流程。你会在结果里清晰地看到比如 北京 这个城市里哪个会员等级贡献最大哪个等级虽然订单数量不多但利润很高。3.5 找出每个城市销售额最高品类这个需求用groupby加rank组合可以做也可以直接用sort_valuesdrop_duplicates实现。我推荐后面这种写法因为它更短、更不容易出错# 先计算每个城市品类的销售额汇总 city_cat df.groupby([城市, 品类])[金额].sum().reset_index() # 按城市分组取金额最大的条目 top_cat city_cat.sort_values([城市, 金额], ascending[True, False]).groupby(城市).head(1) print(top_cat)这个技巧的精髓在于先排序再分组取前几条。groupby().head(1)会在每个组内取排好序的第一行。如果你要取每个城市的前三名把head(1)换成head(3)就行。4. 常见问题与排查技巧实录4.1 为什么 merge 之后行数变多了这是新手最容易慌的情况。我见过最离谱的一次两张表都是几百行merge 之后直接变出几万行。原因基本都是连接键存在重复值。排查方式很简单先对右表的连接键执行duplicated()检查看看有没有重复再对左表执行同样的检查。如果两边都有重复那结果就是笛卡尔积式的多对多连接行数会爆炸式增长。print(users[用户ID].duplicated().sum()) print(orders[用户ID].duplicated().sum())如果确认连接键在右表重复且业务上无法避免比如订单表中一个用户可以有多条订单你要明确自己到底想要什么口径是基于订单的明细连接还是基于用户的去重信息。前者可以直接做 left join后者需要先对右表去重比如只保留用户的最新等级信息users_dedup users.sort_values(注册时间).groupby(用户ID).tail(1)4.2 groupby 之后 Series 和 DataFrame 分不清很多初学者的困惑df.groupby(城市)[金额].sum()返回的是一个 Series而df.groupby(城市)[[金额]].sum()返回的是 DataFrame。两者差别在于[金额]和[[金额]]这组括号的层级。在调试中如果后续要和其他 DataFrame 做merge必须用双括号取 DataFrame如果只是简单打印或者后续转字典Series 就够用。顺手说一下result.to_dict()在 Series 上很好用可以直接把分组结果变成字典方便做映射匹配。4.3 聚合结果缺了某一行数据去哪了groupby默认会丢弃分组键为 NaN 的行。如果你希望不知道城市的订单也保留一个分组建需要在groupby前把 NaN 填充成特殊标记比如df[城市] df[城市].fillna(未知)。这个细节在数据质量较差的场景里非常关键否则你辛辛苦苦清洗的数据可能在分组这一步被静默丢弃了一些记录。另外groupby默认只对数值列做聚合如果你在.agg()里传入了非数值列的计算函数比如求字符串列的第一个值需要用特殊方式处理——first或者自定义 lambda。我看到很多人在聚合字符串列时报错绝大多数是因为不知道agg里可以直接传first。4.4 性能问题数据量大时 groupby 太慢当 DataFrame 行数达到几百万甚至上亿时Pandas 的groupby可能变得非常慢。这里有几个实用的提速手段第一优先选择分类类型存储分组键。如果某个字段的取值种类有限比如城市就 5 种改成category类型df[城市] df[城市].astype(category)这样 groupby 的分组过程会明显加快同时还能大幅减少内存占用。第二尽量避免在 groupby 之后返回超大结果集。如果只是要统计值直接在agg里指定必要列别把无用的高基数列带进去。第三如果数据真的太大Pandas 硬扛不动可以考虑用polars或者转spark处理。但这是后话先学会把 Pandas 的姿势用对。4.5 分组结果重新变成普通表格groupby之后的分组字段默认会变成索引很多后续操作都要求普通列 普通列的结构所以要顺手reset_index()。但是注意这个操作对小数据集无所谓在大数据上会额外消耗时间。如果确认后面不需要reset_index才能进行的分组字段操作可以省略这一步。多级分组后行数较多时reset_index()会把分组字段放回每一行这时候可以顺势用rename把列名改得更规范。我习惯把所有清洗步骤放在流水线前面最后才 reset 和重命名保持处理流程的清爽。5. 从能用代码到会思考数据一些实操心得5.1 合并前先搞清楚主从关系无论是merge还是concat写代码之前先想清楚哪张表是主体哪张表是补充信息。主体表的行数不应该因为连接而改变太多除非你刻意要做多对多的连接。我之前遇到过一个同事报表数据莫名其妙翻倍查了半天发现是连接方向反了本应该用订单表 join 用户表结果写成了用户表 join 订单表——行数变成订单量 × 用户覆盖数。这个判断有个技巧先想我要分析的对象是谁。如果要分析订单主体就是订单表如果要分析用户主体就是用户表。然后连接键的选择、how参数的选择都围绕这个主从关系展开。5.2 聚合口径求和之前先问自己合不合理分组聚合输出的是统计结果这个结果是否可靠取决于你对业务的理解。比如平均订单金额这里有两个隐含选择分子是所有订单金额之和分母是所有订单数量。但如果有些订单是退款订单你算平均客单价的时候是否应该剔除或者是否应该用去重用户数做分母计算用户平均消费贡献同一个表能算出一百种统计口径。写代码前不确认口径算出来的数字再漂亮也只是数字。我的习惯是每个聚合字段旁边注释一个口径说明比如金额.sum()后面写# 不含退款订单交易状态为已成交。代码不规范复盘两行泪。5.3 把常用的多表合并封装成函数如果你在一个数据源复杂的环境里做分析会发现订单表 join 用户表 join 商品表这种操作每隔几天就要写一遍。第一次写可能花了半小时后面每次复制粘贴再改字段名浪费的时间累积起来很恐怖。建议把你所在业务场景里最常见的合并逻辑封装成一个函数输入几个必要参数输出清洗好的宽表。我给自己的项目写过类似load_store_wide_table(start_date, end_date, include_refundFalse)这样的函数参数化日期和退款口径。后面做任何分析只需要调一个函数从底层把数据准备好不仅代码量大幅减少错误率也直线下降。5.4 数据验证任何聚合结果都要能还原最后一条心得也是我吃了不少亏才总结出来的任何 groupby 聚合结果都应该能被原始数据还原。怎么验证随机挑一个分组手工筛出该分组的所有原始记录自己加总一下和聚合结果对比是否一致。如果一致说明代码逻辑没问题如果不一致说明中间的脏数据、连接逻辑、字段选择中至少有一个地方出了问题。# 验证北京这个城市总销售额 validate df[df[城市] 北京][金额].sum() print(validate) # 和 city_stats 里的北京金额对比 print(city_stats[city_stats[城市] 北京][金额_sum].values[0])如果两个数字对不上就往下游查是不是 merge 时产生了重复行是不是 groupby 时分组字段包含了 NaN 被丢弃时间花在验证上远小于花在后续报表数据错误被别人发现的难堪上。6. 进阶技巧分组聚合与透视表的互相转换6.1 pivot_table 与 groupby 的异同pivot_table和groupby在功能上有大量重叠但输出形态不同。pivot_table的结果天然是宽表行是一个分组键列是另一个分组键交叉点是对应的聚合值——这个格式和人脑阅读的表格更接近。而groupby的结果是长表更贴近数据处理的流水线格式。# 用 pivot_table 实现同样的统计 pivot pd.pivot_table( df, index城市, columns会员等级, values金额, aggfuncsum, fill_value0 ) print(pivot)这段代码得到的表格行是城市列是会员等级每个格子是对应组合的销售额总和空白处自动填 0。如果你需要把这份结果画热力图或者导出成 Excel 给别人看这种宽表格式明显更合适。反过来如果你的下一步操作是merge或者继续分组计算用groupby得到的长表更顺手。6.2 宽表与长表的相互转换数据分析中你必然会遇到宽表转长表或者长表转宽表的场景两个核心函数分别是melt和pivot。宽表转长表用melt它把多个列融成两列一列是原列名另一列是对应值长表转宽表用pivot把某一列的值摊开成多个新列。# 宽表转长表 long_df pivot.reset_index().melt( id_vars城市, var_name会员等级, value_name销售额 )这种转换本身不是合并或分组的内容但它是配合分组聚合结果展示的重要技能。当你需要把一份统计好的结果再喂给绘图库做分组柱状图时长表往往比宽表更友好当你需要做横向对比时宽表更直观。两者都掌握才算真正玩转了重组数据。6.3 分组聚合在时间序列上的应用时间序列数据的分组聚合有一个特殊技巧把时间字段切分成不同的时间粒度再分组。比如你有一列精确到秒的下单时间想按天周月汇总销售额可以用pd.Grouper或者先对时间列做dt.floor()。df[下单日期] pd.to_datetime(df[下单时间]).dt.floor(D) daily_sales df.groupby(下单日期)[金额].sum() monthly_sales df.groupby(pd.Grouper(key下单日期, freqM))[金额].sum()这里dt.floor(D)把时间向下取整到天相当于去掉时分秒pd.Grouper(freqM)是按自然月分组。这个技巧在做销售日报、月报时极具实用价值。结合前面讲的城市分组你甚至可以直接按月、按城市做二维汇总形成一份经营分析的核心视图。我也顺便多说一句groupby加时间窗口之后别忘了处理不完整月份或节假日的缺失——很多报表里某个月数据突然大幅下降不是业务崩了而是当月有长假或者数据源延迟未更新。这种时候先看原始数据别急着分析。7. 避坑手册我踩过的那几个坑字符串和数值分别统计的坑groupby分组后如果用.sum()不带aggPandas 会尝试对所有数值列做求和同时忽略字符串列。如果你的 DataFrame 里混着非数值列你要么明确指定列要么用.agg()精确控制。和看上去的用不了相比更隐蔽的问题是你以为它没算字符串列但实际它做了别的操作结果和预期不符。reset_index 之后列名重复当groupby的聚合结果里有两列都叫金额之类的名字时reset_index()会导致列名重复。后续用df[金额]会返回一个 DataFrame 而不是 Series很容易踩坑。解决办法是在展平列名时顺手把重复列改名。Grouper 的 freq 参数语义freqM在旧版本里表示月末在某些版本里会有大小写敏感问题比如m是分钟。如果你发现分组结果的时间点不对检查一下freq的大小写。聚合函数里混入 lambda 的时间开销自定义 lambda 不能向量化性能比内置函数差很多。如果数据量大能用agg([sum, count])解决的不要自作聪明写lambda x: x.sum()。多表 concat 时列数不一致会强制对齐concat默认按列名对齐如果你只是简单地把列顺序不同但结构一致的表拼起来结果会出现很多 NaN。建议拼接前统一列顺序或者拼完后再reindex列。8. 最后的几件趁手工具做合并和分组聚合时我习惯准备几个小工具在代码库里。一个是检查 DataFrame 重复行的函数一个是检查连接键唯一性的函数还有一个是统一列名格式的函数。代码都很短但写完之后十次有八次会复用def check_duplicates(df, keys): dup_count df.duplicated(subsetkeys).sum() print(f重复行数: {dup_count}) return dup_count 0 def normalize_columns(df): df.columns [str(c).strip().lower().replace( , _) for c in df.columns] return dfcheck_duplicates在多表合并之前特别有用——先扫一眼两张表连接键的重复情况能预判 merge 之后的行数规模normalize_columns能避免因为列名大小写差异导致的 merge 失败。这种小函数不需要重复发明轮子但确实省下不少调试时间。说实话merge和groupby这两个操作你哪怕只是把本文里的例子顺手跑一遍把它们的使用场景刻进肌肉记忆里就已经能在大部分数据分析任务里游刃有余了。我自己的体会是这两个操作的代码量通常只占整个分析脚本的三成但思考时间占了七成——思考清楚表怎么连、数据怎么聚合、口径怎么定后面写代码就是一马平川。最后想提一个越早学会越好的习惯每次做完一步合并或聚合顺手用一个小 test 验证结果不信就还原到原始数据抽查几个分组。等数据量大了、逻辑复杂了你一定会感谢当初这个看起来多此一举的动作。