处理多张表的数据时,大部分时间其实不是花在“算”上,而是花在“拼”和“分”上。这里的“拼”指的是把分散在不同表格里的数据合并到一起,“分”则是按某个维度把数据拆开再聚合。Pandas 里对应的就是 DataFrame 的合并(concat / merge / join)和分组聚合(groupby)。这是数据分析流程里绕不开的一环,只要做过一次稍微像样的数据分析,就一定会用到这两类操作。这篇内容从实际场景出发,把合并和分组聚合的常用写法、背后的逻辑、以及踩过的坑都过一遍,适合刚入门 Pandas 但想把这些操作真正用熟练的同学。
我自己的体会是,很多教程分开讲 concat、merge、groupby 的时候都很清楚,但一到真实数据上就容易乱。原因在于真实场景里数据不会乖乖等你,索引会对不上、列名会重复、分组维度会嵌套、聚合完之后索引还乱糟糟。所以这篇会刻意按“为什么要这么写”的思路来讲,配合可直接跑通的代码示例,尽量让每个操作都能落到实际使用场景里去理解。
1. 内容整体设计与思路拆解
1.1 为什么合并和分组聚合总是成对出现
数据从采集到能用来分析,中间经历的清洗步骤各不一样,但有两件事几乎是固定的:需要把多个来源的数据拼接在一起,然后按业务维度做汇总统计。
举个例子,电商场景下订单表、用户表、商品表通常是分开存储的。订单表里有 user_id 和 product_id,但如果你想知道“哪个城市的用户贡献了最多的销售额”,就得先把订单表和用户表关联起来,拿到城市字段,再按城市做聚合。这就是合并和分组聚合配合使用的最典型场景。
合并解决的是“数据在哪里”的问题——把散落的信息拼成一张完整的大宽表。分组聚合解决的是“数据意味着什么”的问题——从具体记录里提炼出规律和结论。分析流程中这两个环节缺一不可,而且顺序通常也是固定的:先合并,后聚合。
1.2 先把三个核心概念理清楚
Pandas 的合并操作有三个常用工具:concat、merge、join。很多同学容易搞混,其实它们各有侧重。
concat 是“拼接”,无论是横向还是纵向,它默认不关心列名是否匹配,只负责把多份数据按轴方向堆在一起。
merge 是“关联”,类似 SQL 里的 join,它通过一个或多个 key 列把两份数据匹配起来,这才是真正意义上的“合并”。
join 其实是 merge 按索引关联的便捷版本,如果你的关联条件是索引而不是普通列,用 join 写起来会省事很多。
分组聚合的核心则是 groupby,它做的事情可以拆成两步:先按某个(或某几个)列把数据切成若干组,然后对每一组应用聚合函数(求和、均值、计数等),最后再把结果汇总返回。
理解这几个概念的区别,后面写代码就不会出现“该用 merge 的时候用了 concat,结果列全成了 NaN”这种问题。
2. 数据合并实操:把多张表拼成一张表
2.1 concat 的两种方向与容易踩的坑
concat 最简单的用法是纵向拼接多份结构相同的 DataFrame,比如按月份拆分的销售记录,每个月一张表,字段完全一致,月底要合并成全年数据,这种场景直接用 concat 纵向拼接就对了。
import pandas as pd jan = pd.DataFrame({'user_id': [1, 2, 3], 'amount': [100, 200, 150]}) feb = pd.DataFrame({'user_id': [4, 5, 6], 'amount': [300, 250, 180]}) result = pd.concat([jan, feb], axis=0, ignore_index=True) print(result)axis=0 表示沿着行方向堆叠,也就是把第二个 DataFrame 的行追加到第一个下面。ignore_index=True 非常关键,它会让合并后的结果重新生成 0 到 n-1 的索引,否则你会看到索引是 0、1、2、0、1、2 这样的重复状态,后面再做筛选或去重时容易出现莫名其妙的 bug。
concat 也可以做横向拼接,也就是 axis=1。这时候它把两个 DataFrame 按列并排放置,相当于把两张表的列拼成一张更宽的表。横向拼接时有一个默认行为很多人不知道:它是按索引对齐的,而不是按位置对齐。
left = pd.DataFrame({'key': ['A', 'B', 'C'], 'val': [1, 2, 3]}) right = pd.DataFrame({'key': ['B', 'C', 'D'], 'val2': [4, 5, 6]}) wide = pd.concat([left, right], axis=1) print(wide)这段代码运行后,left 和 right 的索引都是 0、1、2,但它俩对应的 key 并不相同。concat 不管这些,它只按索引位置把列拼在一起,于是会出现左边 key 是 A,右边 key 是 B 这种错位的宽表。这就是滥用 concat 横向拼接的典型问题:如果你要做的是“按 key 关联”,应该用 merge,而不是 concat。
另外 concat 还有一个 join 参数可以控制拼接时如何处理索引对齐,默认是 outer,也就是保留所有索引;改成 inner 则只保留交集。
提示:concat 适合结构相同的数据直接堆叠,比如按月、按日拆分的文件合并。涉及按某个 key 关联两张表时,优先考虑 merge。
2.2 merge:真正的关联操作
merge 的基本用法是指定两个 DataFrame 和一个共同的关联列,然后选择关联方式。关联方式通过 how 参数控制,有四种:inner、left、right、outer。
orders = pd.DataFrame({ 'order_id': [101, 102, 103], 'user_id': [1, 2, 1], 'amount': [50, 300, 80] }) users = pd.DataFrame({ 'user_id': [1, 2, 3], 'city': ['上海', '北京', '广州'] }) merged = pd.merge(orders, users, on='user_id', how='left') print(merged)这段代码用 user_id 作为关联键,把用户表中的城市信息补充到订单表里。how='left' 表示以左表(orders)为基准,左表所有行都保留,右表有匹配的才填进来,没匹配到的城市就是 NaN。
选择 how 参数是新手最容易纠结的地方,我的建议是:先想清楚“分析结果需要保留哪些行”。如果订单表是主表,每条订单都必须存在,用户信息只是补充,那就用 left。如果只想要两边都能匹配上的记录(比如只想分析有订单又有用户信息的记录),用 inner。如果想知道哪些用户在订单表里完全没有数据,可以用 right 或者 outer 再筛 NaN。
merge 还有一个常用场景是关联列名不一致,比如左表叫 user_id,右表叫 id。这时候需要分别指定 left_on 和 right_on:
users_rename = pd.DataFrame({ 'id': [1, 2, 3], 'city': ['上海', '北京', '广州'] }) merged2 = pd.merge(orders, users_rename, left_on='user_id', right_on='id', how='left')关联成功后,结果里会同时保留 user_id 和 id 两列,如果你只需要其中一个,可以用 drop 删掉多余的列。
多键关联也是 merge 的常见用法,比如订单表和商品快照表需要同时按 product_id 和 date 才能唯一匹配,这时候 on 参数可以传一个列表:
pd.merge(df1, df2, on=['product_id', 'date'], how='inner')多键关联在真实业务里非常常见,尤其是事实表和维度表之间存在时间维度上的变化时,单靠一个键往往会关联出重复行。
提示:merge 关联后如果发现行数比左表还多,大概率是右表的关联键有重复值,这是最常见的隐患,后面会专门讲。
2.3 join 与 merge 的取舍
join 是 merge 的索引关联版本。当两个 DataFrame 的关联条件恰好就是索引时,用 join 可以少写一点代码:
df_a = pd.DataFrame({'val': [1, 2, 3]}, index=['x', 'y', 'z']) df_b = pd.DataFrame({'info': [10, 20, 30]}, index=['y', 'z', 'w']) joined = df_a.join(df_b, how='left') print(joined)这段代码按索引 x、y、z、w 做左连接,df_a 的所有行保留,df_b 中能匹配到的索引(y、z)补充进来,w 在左表没有对应索引所以不会出现。
其实 join 也可以用 on 参数指定普通列来关联,但它背后仍然会把该列设为索引再合并,遇到关联键是索引列且你不想动原表时,join 写起来确实更直观。
我的习惯是:数据里有关联键列,就用 merge;关联键就是索引,或者想把某个列当作索引来做连接,就用 join。两种写法只是入口不同,底层都是类似的哈希匹配逻辑。
3. 分组聚合:从明细数据到业务结论
3.1 groupby 基础用法与分组后的结构变化
groupby 的经典写法是:df.groupby('列名')['目标列'].聚合函数()。这一行代码的背后做了三件事:按列分组、选择要聚合的列、执行聚合计算。
sales = pd.DataFrame({ 'city': ['上海', '北京', '上海', '广州', '北京', '广州'], 'product': ['A', 'B', 'A', 'B', 'A', 'B'], 'amount': [100, 200, 150, 300, 120, 250] }) city_sum = sales.groupby('city')['amount'].sum() print(city_sum)运行结果是一个 Series,索引是城市名,值是各城市的销售总额。注意这个结果其实是一个带索引的 Series,方便后续按城市名索引取值,但如果想把它作为普通 DataFrame 用,就需要处理索引。
groupby 支持一次按多个列分组,传入一个列表即可,这样会得到 MultiIndex。比如同时按城市和产品分组:
grouped = sales.groupby(['city', 'product'])['amount'].sum() print(grouped)这时分组维度是两级:city 和 product,取值时可以通过元组来定位,比如 summary['上海', 'A'] 就能拿到上海 A 产品的销售总额。
分组聚合的结果往往让人很意外的一点是:原本的 DataFrame 里那些非分组、非聚合的列不会出现在结果里。因为你只对 amount 做了求和,其他列没有参与任何聚合计算,Pandas 会直接丢弃或把结果设计成只保留目标列。
3.2 多列分组时的 MultiIndex 处理技巧
MultiIndex 是分组聚合绕不开的话题。当分组维度超过两个时,结果默认是行索引的多级结构,这在后续处理中会遇到各种问题。
一个问题是怎么把 MultiIndex 转回普通列。最直接的方法是 reset_index。它把索引里所有的层级都还原成普通列。
result = sales.groupby(['city', 'product'])['amount'].sum().reset_index() print(result)这样得到的结果是一个普通的 DataFrame,包含 city、product、amount 三列。reset_index 的同时还能传入 name 参数给聚合结果列重命名,比如 reset_index(name='total_amount'),这样更明确。
另一个常见操作是对分组后的结果继续做筛选或排序。比如想找每个城市销售额最高的产品,靠头脑算不如靠代码。先把分组结果 reset 成表,再按城市分组做 rank:
result = sales.groupby(['city', 'product'])['amount'].sum().reset_index(name='total') result['rank'] = result.groupby('city')['total'].rank(ascending=False) top = result[result['rank'] == 1] print(top)这个组合逻辑在实际分析中非常常用:先多维度聚合,再在组内继续排序,拿到每组排名最高的记录。理解 MultiIndex 的生成和还原,是灵活运用 groupby 的前提。
提示:reset_index 出现之前,很多人会用 as_index=False 直接在 groupby 时关掉索引形式,这也是一种避免后续 reset 的写法。
sales.groupby(['city', 'product'], as_index=False)['amount'].sum()3.3 一次聚合多种统计量:agg 的核心用法
groupby 配合单个聚合函数很基础,但分析场景里往往一次要算好几个指标:总和、均值、最大值、最小值、计数。每个指标单独写一行 groupby 再合并太傻了,agg 就是干这个的。
agg 的用法有两种。第一种是对所有选定列应用相同的聚合函数列表:
result = sales.groupby('city')['amount'].agg(['sum', 'mean', 'max', 'min', 'count']) print(result)这会生成一个 DataFrame,行是城市,列是 sum、mean、max、min、count,每个城市一行汇总。
第二种是对不同列应用不同的聚合函数,传入一个字典:
orders = pd.DataFrame({ 'city': ['上海', '北京', '上海', '广州'], 'amount': [100, 200, 150, 300], 'order_cnt': [5, 8, 6, 10] }) result = orders.groupby('city').agg({ 'amount': ['sum', 'mean'], 'order_cnt': 'sum' }) print(result)字典的键是要聚合的列名,值是要应用的函数(可以是字符串函数名,也可以是自定义函数、lambda)。结果会生成多层列索引,列名分别是 amount、order_cnt 以及它们对应的聚合函数名。
agg 最大的优势是把多种聚合逻辑集中在一句代码里,避免多次遍历数据。数据量大的时候,这种写法在性能上的差异非常明显。
3.4 transform 与 apply:聚合结果如何回到原行
分组聚合的结果是每个组一行,但实际分析中经常需要把聚合值“广播”回原表的每一行。比如计算每个城市销售额的占比,需要先知道每个城市的总销售额,然后把总销售额拼回原始订单明细里,再算每一行占城市总额的比例。
这里有两种思路。一种是通过 merge 把聚合结果关联回原表,另一种是用 transform 直接生成与原表相同行数的结果:
sales['city_total'] = sales.groupby('city')['amount'].transform('sum') sales['pct'] = sales['amount'] / sales['city_total']transform 的返回值形状和原表一致,它会自动把每组的聚合结果映射回组内每一行。这样就不需要先聚合再 merge,代码简洁而且不容易出错。
apply 则更自由,它可以把整个子 DataFrame 传给你的自定义函数,让你在组内做更复杂的操作。比如对每个城市的产品做排序后取前两条:
def top_two(group): return group.nlargest(2, 'amount') result = sales.groupby('city', group_keys=False).apply(top_two)理解 transform 和 apply 的区别:transform 要求返回与原组相同长度的结果,apply 则可以返回任意形状。实际使用中能用 transform 解决的不要用 apply,因为 transform 通常更快。
4. 常见问题与排查技巧实录
4.1 合并后行数变多或变少
这是一个几乎每个人都会遇到的问题。merge 之后行数莫名其妙增加了,原因几乎都是关联键在右表中不唯一。
比如订单表每个用户有多条订单,用户表里 user_id 是唯一的,这时按 user_id merge 没问题。但如果用户表本身有重复的 user_id,merge 时右表的每个重复值都会和左表行配对,导致结果行数膨胀。
排查方法很简单:在合并前检查右表关联键的唯一性。
duplicated_count = users.duplicated(subset=['user_id']).sum() print(f'重复 user_id 数量: {duplicated_count}')如果发现重复,先决定是保留一条还是按业务逻辑再聚合一层,再去 merge。行数变少则通常是使用了 inner join,某些关联不到的行被过滤掉了,检查过滤是否符合业务预期。
4.2 groupby 结果的索引到底要不要处理
这个问题在实战里经常引起困惑。很多人 groupby 出来直接赋值给一个新变量,后面代码却报错或者结果显示不对,原因多是他以为结果是普通 DataFrame,但实际上是带索引的 Series 或带 MultiIndex 的 DataFrame。
判断办法很简单:打印结果的 index 看看。如果索引是 city、product 这种业务字段,说明它们还不是普通列。要让它们变成普通列,用 reset_index。
这里顺便说一个实际场景:既要保留明细行数,又想要聚合结果,最简单的就是 transform,前面已经展示过。如果既要聚合结果,又要用它去和其他表关联,那就用 merge,把 reset_index 后的结果作为一个普通表参与关联。
注意:groupby 后直接对结果列做赋值操作,比如 result['new_col'] = ...,如果结果本身就是 Series,会直接报错。一定要先把结果转成 DataFrame 再操作。
4.3 常见错误速查表
| 错误现象 | 可能原因 | 解决办法 |
|---|---|---|
| merge 后行数翻倍 | 右表关联键有重复 | 先检查并处理重复值 |
| concat 后列错位 | 使用了 axis=1 但数据需要按 key 关联 | 改用 merge |
| groupby 结果无法用列名取值 | 结果是 Series 或 MultiIndex | 用 reset_index 或检查是否该用中括号 |
| 想给 groupby 结果新增列但报错 | 结果不是普通 DataFrame | 先 reset_index 再做列操作 |
| agg 后列名变成多层 | 对多列用了聚合列表 | 用字典重命名或用 reset_index 整理 |
| transform 结果长度不对 | 对非分组列做了 transform | 检查分组键和目标列是否一致 |
这些问题的共同根源在于:没有准确判断中间结果的类型和索引状态。我的经验是每做一步关键操作,就打印一下 shape 和 index,用这两个信息能排查掉 90% 以上的奇怪问题。
4.4 合并与分组联动的经典报错场景
举个例子:先 groupby 得出每个城市的销售额,再想把城市名和另一个维度表的城市代码关联。如果你直接 merge 一个 Series 和一个 DataFrame,可能连列都对不上。
正确做法是先把聚合结果 reset_index,确保 city 是列而不是索引,然后再 merge:
agg = sales.groupby('city', as_index=False)['amount'].sum() city_code = pd.DataFrame({'city': ['上海', '北京'], 'code': ['SH', 'BJ']}) result = pd.merge(agg, city_code, on='city', how='left')as_index=False 的写法在这类场景里非常省心。它让分组结果从一开始就把分组键保留为列,而不是移到索引里,后面再跟其他表 merge 时就不用额外 reset。我自己的习惯是,只要分组之后还要参与关联或后续处理,一律用 as_index=False,省得多次 reset。
如果分组维度是多个列,也同样可以用 as_index=False,MultiIndex 就不会出现,所有分组键都直接成为结果集的普通列。
5. 一个完整小案例:订单数据的城市维度汇总
把前面讲的内容串起来,用一个小案例模拟真实分析流程。假设我们有订单明细表、用户基础表,想分析每个城市的订单量和销售额,并找出销售额最高的产品。
数据构造如下:
orders = pd.DataFrame({ 'order_id': [101, 102, 103, 104, 105], 'user_id': [1, 2, 1, 3, 2], 'product': ['A', 'B', 'A', 'C', 'C'], 'amount': [100, 200, 150, 300, 120] }) users = pd.DataFrame({ 'user_id': [1, 2, 3], 'city': ['上海', '北京', '广州'] })第一步,先关联订单和用户,把城市带进订单明细:
df = pd.merge(orders, users, on='user_id', how='left')第二步,按城市汇总订单量和销售额:
city_stats = df.groupby('city', as_index=False).agg( order_cnt=('order_id', 'count'), total_amount=('amount', 'sum') )这里用了一种比较新的 agg 写法:指定新列名、来源列、聚合函数名,生成的结果列名直接符合预期,不用之后改名。agg 的 语法在 Pandas 较新版本里可用,如果版本老,可以用字典或后面 reset 的方式代替。
第三步,找出销售额最高的城市和城市内最畅销的产品:
top_city = city_stats.sort_values('total_amount', ascending=False).iloc[0] city_product = df.groupby(['city', 'product'])['amount'].sum().reset_index(name='total') idx = city_product.groupby('city')['total'].idxmax() top_product_per_city = city_product.loc[idx] print(top_city) print(top_product_per_city)利用 idxmax 找到每个城市总销售额最大的产品所在行索引,再通过 loc 取行,这是处理“组内最值所在行”的常用技巧。整个过程跑下来,从两张原始表到最后业务结论,每步的数据类型和索引状态都能清晰掌握。
这个案例的代码在你自己数据分析练习中可以直接当模板用,换成自己的字段名和数据就能跑起来。按这个顺序走一遍之后,merge 和 groupby 的配合就不再是零散的知识点了,而是完整可复用的分析路径。
我把这种“合并—聚合—列还原—组内筛选”的流程跑了不知道多少遍,后来基本养成一个习惯:所有步骤里,哪里用了 groupby,哪里用了 merge,都会先想清楚这一步骤的结果是 DataFrame 还是 Series、索引是业务列还是数字索引。判断清楚了,后面几乎不会有突发性的报错。