merge 会静默地放大你的行数
上一章的 groupby 会安静地让数据变少,这一章的 merge 会安静地让数据变多——而变多比变少危险得多,因为「多」不会触发任何直觉上的警报。表还在,字段还在,每一行看起来都合法,只是某些行被复制了好几份,于是这张表上算出来的每一个平均值都是错的。这一章把这笔账算清,并给出唯一一个能当场拦住它的参数。
一张客户表(5 个客户,各有年龄),一张订单表(11 条订单,其中 1 号客户下了 7 单)。你把两张表按客户号合并,然后算「客户的平均年龄」。
cust["age"].mean() # 40.00 cust.merge(orders, on="cid")["age"].mean() # ?
问:合并之后算出来的平均年龄是多少?
一对多合并,就是一次可控的复制
merge 做的事和 SQL 的 JOIN 一字不差:左表的每一行,去右表里找所有键相同的行,配成一对。如果右边有 7 行匹配,左边那一行就出现 7 次。
本机跑一遍:
客户表 5 行 年龄 20 30 40 50 60 平均 40.00 订单表 11 行 其中 1 号客户占 7 条 merge 之后 11 行 1 号客户出现 7 次 平均年龄 29.09 ★ 偏低 27.3%
为什么偏低?因为 1 号客户 20 岁,最年轻,而他的年龄被算了 7 遍。合并之后的表,每一行不再代表一个客户,而是代表一个「客户 × 订单」的组合。在这张表上算「客户的平均年龄」,实际上算的是「按订单数加权的平均年龄」——两个完全不同的量。
这是一类特别难发现的错误,因为:
- 结果在合理范围内(29.09 岁是个正常年龄,不像 −1 或 999 那样扎眼);
- 合并本身没有报错、没有警告;
- 你如果不去数行数,就看不到任何异常;
- 它常常发生在流程中间,等你发现结果不对时,已经隔了七八步。
唯一那个能当场拦住的参数
merge 有一个存在感很低、但价值极高的参数:validate。它让你把「我以为这次合并是什么关系」写进代码:
cust.merge(orders, on="cid", validate="one_to_one")
MergeError: Merge keys are not unique in right dataset; not a one-to-one merge
当场报错,报的是关系,不是数据。四个可选值:
| 值 | 断言的是 | 典型场景 |
|---|---|---|
"one_to_one" | 两边的键都唯一 | 拼两张主数据表 |
"one_to_many" | 左边唯一,右边可以重复 | 客户 ← 订单,行数一定会涨 |
"many_to_one" | 右边唯一 | 订单 → 补上客户信息,最常用、最安全 |
"many_to_many" | 什么都不保证 | 笛卡尔式爆炸的温床 |
一条实用的纪律:每写一次 merge,就写一次 validate。它不花运行时间(pandas 本来就要建索引),却能把「我以为的关系」变成一句会被检验的断言。写不出来说明你自己也没想清楚——那更该写。
还有一个配套的诊断参数 indicator=True,它会加一列告诉你每一行来自哪边:
m = left.merge(right, on="k", how="outer", indicator=True) m["_merge"].value_counts() # both / left_only / right_only 各多少行
另一个方向:inner join 会安静地丢行
合并的默认方式是 how="inner"——只保留两边都有的键。这是另一半的账:
左表 5 行(键 1..5),右表 3 行(键 1..3) how="inner" → 3 行 ★ 默认,丢了 2 行 how="left" → 5 行 右边没有的补 NaN how="outer" → 5 行 两边的键都留
「补充一点信息」这种意图,写出来几乎总是 how="left":左表是主体,右表只是来贴标签的。而默认的 inner 会让「右表里查不到的那些左表记录」直接消失。
所以合并这件事,有两个方向的静默事故,正好相反:
| 症状 | 原因 | 怎么防 |
|---|---|---|
| 行数变多,均值全错 | 一对多,左表的行被复制 | validate="many_to_one" |
| 行数变少,某些记录消失 | 默认 inner,右表没覆盖全 | 显式写 how="left" + indicator=True |
| 行数变成 0,一条都没合上 | 两边键的 dtype 不同 | 合并前对一下 df.dtypes |
最后那一条值得单独提,因为它的报错信息很好认:
ValueError: You are trying to merge on int64 and str columns for key 'k'. If you wish to proceed you should use pd.concat
一边的客户号是整数 1,另一边是字符串 "1"——从 CSV 读进来时特别容易这样(一边有前导零,一边没有)。pandas 3 会直接拦住你;在更早的版本里,它可能给你一张零行的表,然后你对着一张空表找半天。
pandas 里有三个方法长得像但用途不同,值得一次分清:
merge:按列做连接,最接近 SQL 的JOIN。默认inner。日常首选。join:按 index 做连接(也可以指定左边用哪一列)。默认left。本质是merge的一个便捷包装。concat:不做连接,只是把几张表摞起来(默认竖着摞)。它会按 index/列名对齐——第 6 章那套规则在这里同样生效。
一个常见误用:想「把两张表并排放」于是写 pd.concat([a, b], axis=1)。这会按 index 对齐,如果两张表的 index 不一致(比如一张过滤过),结果是一张又长又空的表。并排放数据几乎总该用 merge,因为它逼你写出连接键。
那个 40.00 变 29.09,十二行:
import pandas as pd
cust = pd.DataFrame({"cid": [1, 2, 3, 4, 5],
"age": [20, 30, 40, 50, 60]})
orders = pd.DataFrame({"cid": [1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 5],
"amount": [10] * 7 + [20, 30, 40, 50]})
m = cust.merge(orders, on="cid")
print(len(cust), "→", len(m)) # 5 → 11
print("%.2f → %.2f" % (cust["age"].mean(), m["age"].mean())) # 40.00 → 29.09
# 正确算法:先在合并后的表上去重回客户粒度,或者干脆别在这张表上算客户指标
print("%.2f" % m.drop_duplicates("cid")["age"].mean()) # 40.00
# 让它当场报错
cust.merge(orders, on="cid", validate="one_to_one") # MergeError
另外两个方向:
left = pd.DataFrame({"k": [1, 2, 3, 4, 5], "x": [1, 2, 3, 4, 5]})
right = pd.DataFrame({"k": [1, 2, 3], "y": [9, 9, 9]})
print(len(left.merge(right, on="k")), # 3 ← 默认 inner,丢了 2 行
len(left.merge(right, on="k", how="left"))) # 5
bad = pd.DataFrame({"k": ["1", "2", "3"], "y": [9, 9, 9]})
left.merge(bad, on="k") # ValueError: int64 and str columns
python3 -c "import pandas as pd;c=pd.DataFrame({'cid':[1,2,3,4,5],'age':[20,30,40,50,60]});o=pd.DataFrame({'cid':[1]*7+[2,3,4,5]});print(len(c.merge(o,on='cid')), round(c.merge(o,on='cid')['age'].mean(),2))"
养成一个动作:每次 merge 前后各打印一次 len(df)。这是全书性价比最高的两行调试代码。
- 「DAU 突然涨了三倍」的那类事故。数据仓库里最常见的一类线上故障:某张维表出现了重复键(比如一个用户被写了两条记录),下游所有关联这张表的指标全部翻倍。这就是这一章那个 29.09,只是发生在一亿行上,而且进了老板的周报。成熟的数仓会对每张维表加「主键唯一」的测试(dbt 的
unique测试、Great Expectations 的期望),本质就是validate=。 - Excel 的 VLOOKUP 反而更安全。它只返回第一个匹配项,所以不会放大行数——代价是它会安静地忽略其余匹配项。两种默认,两种事故:pandas 复制得太多,VLOOKUP 丢得太多。
- ORM 里的 N+1 和「重复行」。Hibernate/Room 里
JOIN FETCH一对多关联时,父实体会在结果集里重复出现,所以需要DISTINCT或者用Set装。同一个问题,换了个生态。 - 为什么数据建模要讲「粒度」。星型模型里事实表的每一行代表什么,是设计时第一个要定死的东西。这一章那张合并后的表,粒度是「客户 × 订单」,不是「客户」——所有的错误都源于在一个粒度上算了另一个粒度的指标。
「合并只是把两张表的字段拼在一起,数据本身不会变,所以合完再算什么都一样。」
合并会改变每一行代表什么。合并前一行是一个客户,合并后一行是一个「客户 × 订单」。一旦这个「一行代表什么」变了,所有的计数、求和、平均全部换了含义——而字段名一个都没变,所以代码读起来完全正常。
这个误解特别顽固,因为它在一对一合并时确实成立。团队里的代码常常是这样烂掉的:一开始右表的键真的唯一,半年后业务允许一个客户有多条记录,那张早就写好的报表就开始悄悄输出错的数——没有任何一行代码需要改动,它自己就错了。
判据两条:(一)每次 merge 都写 validate=,把假设变成断言;(二)合并前后各打一次 len(),行数变了就停下来想一想是不是你要的。这两条加起来不到十秒钟,挡掉的是这一卷里最贵的一类错误。
正确答案是 B:29.09。1 号客户 20 岁,他的年龄被复制了 7 份,把均值拉低了 27.3%。
A 「合并不改变客户」——合并确实没有改变客户,但它改变了表。这两件事的区别正是这一章的全部内容:表是数据的一种摆法,而统计量算的是摆法,不是事实。 C 「pandas 会拦住」——默认不会。你要它拦它才拦:validate="one_to_one" 会抛 MergeError,原文是 Merge keys are not unique in right dataset。这个参数是这一章唯一需要记住的 API。
D 「去重后重新平均」——pandas 不会替你去重。要回到客户粒度必须自己写 drop_duplicates("cid"),或者干脆不在合并后的表上算客户指标。后者更好:算什么指标,就在什么粒度的表上算。
validate=。自己检查:合并前后 len()、indicator=True。
这一章的一句话
merge 改的不只是列,是「一行代表什么」;一对多会把左表的行复制成好几份,让这张表上的每个平均值都变成加权平均——而唯一能当场拦住它的,是一个几乎没人写的 validate 参数。
卷 II 到此结束。你手上的列现在有了名字,也有了名字带来的四样麻烦:对齐产生的 NaN、位置与标签的歧义、缺失值的三个假设、以及合并造成的行数变化。
下一卷把这些列接到眼睛上。开头第一件事就要重新摆一次形状:同样 12 个数,一张表把「城市」藏在列名里(4 × 4),另一张把它变成一列(12 × 3)。前者你只能一根一根手画,后者一行就能画出带分面、带配色的图。这一次 melt,决定了后面四章你是在写代码还是在描述数据。