跳到正文

Pandas数据合并:merge、join 与 concat

实验数据通常分散在多张表里:一张记录观测值,一张记录样本的分组和处理条件,还有一张是物种、试剂的对照信息。合并(join)就是把它们按共同的键拼成一张分析用的表。这一步不出错的前提是想清楚一件事——键在两边是否唯一。唯一性没确认就合并,行数会悄悄膨胀,后面的统计量全是错的,而且不会报错。

import pandas as pd
from sklearn.datasets import load_iris
df = load_iris(as_frame=True).frame
df.columns = ["sepal_len", "sepal_wid", "petal_len", "petal_wid", "species"]
df["species"] = df["species"].map({0: "setosa", 1: "versicolor", 2: "virginica"})
obs = df[["species", "sepal_len", "petal_len"]].copy()
obs.insert(0, "sample_id", range(1, len(obs) + 1))
info = pd.DataFrame({
"species": ["setosa", "versicolor", "virginica"],
"cn_name": ["山鸢尾", "变色鸢尾", "维吉尼亚鸢尾"],
"site": ["A", "B", "A"],
})
print(obs.head(3))
print(info)
sample_id species sepal_len petal_len
0 1 setosa 5.1 1.4
1 2 setosa 4.9 1.4
2 3 setosa 4.7 1.3
species cn_name site
0 setosa 山鸢尾 A
1 versicolor 变色鸢尾 B
2 virginica 维吉尼亚鸢尾 A

obs 里 150 行观测,info 里 3 行物种信息。物种在 info 里是唯一的,在 obs 里重复出现 50 次——这种「一边唯一、一边重复」的结构叫多对一(many-to-one),是最常见的合并场景,也是最安全的。

how 参数决定两边的键怎么取舍:

how 保留哪些行 什么时候用
inner 两边的键都存在的行 只分析信息齐全的样本(默认值)
left 左表全部行,右表缺的补 NaN 以观测表为准,补充信息可有可无
right 右表全部行,左表缺的补 NaN 少见,通常调换表的顺序用 left
outer 两边的键全部保留 核对两边的键是否对得上(数据核查)
m = obs.merge(info, on="species", how="inner")
print(m.head(3))
print("合并后行数:", len(m))
sample_id species sepal_len petal_len cn_name site
0 1 setosa 5.1 1.4 山鸢尾 A
1 2 setosa 4.9 1.4 山鸢尾 A
2 3 setosa 4.7 1.3 山鸢尾 A
合并后行数: 150

on="species" 指定连接键,两边的列名一致时只写一次。行数没变,说明 info 的键确实唯一——这就是合并后立刻检查行数的价值。

如果有多出来的样本,left 连接会把它们留下,缺的信息填 NaN

obs2 = obs.copy()
obs2.loc[0, "species"] = "unknown"
m2 = obs2.merge(info, on="species", how="left")
print(m2.head(2))
print("右表没匹配上的行数:", m2["site"].isna().sum())
sample_id species sepal_len petal_len cn_name site
0 1 unknown 5.1 1.4 NaN NaN
1 2 setosa 4.9 1.4 山鸢尾 A
右表没匹配上的行数: 1

这里出现了实际的数据问题:样本的物种写成了 unknown,在物种表里查不到对应信息。这种样本在真实数据里往往不是笔误,而是标签在某个环节丢了——它仍然进了统计,只是分组信息是空的。left 连接之后,检查右表列有多少 NaN,能直接暴露这类问题inner 连接会把它们静默丢掉,样本量少了你不知道。

outer 保留两边的全部键,最适合做核对。用一张迷你表看得更清楚:

left_small = pd.DataFrame({"species": ["setosa", "setosa", "versicolor"], "v": [1, 2, 3]})
right_small = pd.DataFrame({"species": ["setosa", "versicolor", "virginica"], "site": ["A", "B", "A"]})
print(left_small.merge(right_small, on="species", how="outer"))
print(len(left_small), len(right_small), "->", len(left_small.merge(right_small, on="species", how="outer")))
species v site
0 setosa 1.0 A
1 setosa 2.0 A
2 versicolor 3.0 B
3 virginica NaN A
3 3 -> 4

virginica 在左表里没有观测,仍然被保留,v 列填 NaN。真实数据里跑一遍 outer,凡是「半边全是 NaN」的行,就是两张表的键对不上的地方:要么物种表里有的组根本没进实验,要么观测表里出现了物种表没登记的标签。这是核对数据最直接的手段,比人眼比对两份名单快得多。

两边的键列名不一样时(比如一边叫 species、另一边叫 spl),用 left_on / right_on

treat = pd.DataFrame({"spl": ["setosa", "virginica"], "dose": [10, 20]})
m4 = obs.merge(treat, left_on="species", right_on="spl", how="inner")
print(m4.head(2))
print(list(m4.columns))
sample_id species sepal_len petal_len spl dose
0 1 setosa 5.1 1.4 setosa 10
1 2 setosa 4.9 1.4 setosa 10
['sample_id', 'species', 'sepal_len', 'petal_len', 'spl', 'dose']

后果是两张键列都留下了speciesspl 内容完全一样,多一列冗余。表宽一点无所谓,但如果要按位置取列或者 to_csv 给下游用,多出来的列迟早有人踩到。合并后 drop(columns="spl") 一行删掉,养成习惯。

两表有同名但含义不同的列时,pandas 自动加后缀 _x_y

a = pd.DataFrame({"id": [1, 2], "value": [10, 20]})
b = pd.DataFrame({"id": [1, 2], "value": [1.5, 2.5]})
print(a.merge(b, on="id", suffixes=("_raw", "_norm")))
id value_raw value_norm
0 1 10 1.5
1 2 20 2.5

_x / _y 这种默认后缀在真实分析里容易埋雷:一天之后你分不清哪个是原始值、哪个是标准化值。只要两边有同名列,就显式写 suffixes,名字里带上含义。写完之后确认一遍列名,别让 value_x 出现在最终结果里。

这是最容易出事的一种。两边都不唯一时,合并结果的行数是两边匹配行数的笛卡尔积

left = pd.DataFrame({"id": [1, 1, 2], "x": ["a", "b", "c"]})
right = pd.DataFrame({"id": [1, 1, 2], "y": ["p", "q", "r"]})
print(left.merge(right, on="id"))
print("left:", len(left), "right:", len(right), "merged:", len(left.merge(right, on="id")))
id x y
0 1 a p
1 1 a q
2 1 b p
3 1 b q
4 2 c r
left: 3 right: 3 merged: 5

左边 3 行、右边 3 行,合出来 5 行。id=1 在两边各出现两次,2 × 2 = 4 行,加上 id=2 的 1 行,正好 5 行。样本量从 3 变成 5,均值、标准差、p 值全部建立在错误的分母上。

合并前后对比行数是最基本的动作

before = len(obs)
merged = obs.merge(info, on="species", how="left")
print(before, len(merged), len(merged) == before)
150 150 True

更省事的做法是让 pandas 自己校验。validate 参数声明你期望的键关系,不满足直接抛错:

left.merge(right, on="id", validate="one_to_one")
MergeError: Merge keys are not unique in either left or right dataset; not a one-to-one merge.
Duplicates in left:
id
1 ...
Duplicates in right:
id
1 ...

可选的取值有 "1:1""1:m""m:1""m:m"one_to_onemany_to_one 这些写法等价)。上面那对多对多的表,只有 "m:m" 不报错——这恰恰说明它有膨胀风险;而真正的多对一(观测表多行对一个物种)写 validate="many_to_one" 就能通过。写脚本时把期望的键关系写进 validate,比事后查行数更可靠——尤其是数据每周更新、这周合并正常、下周源表突然多出一条重复记录这种情况。

merge 是按列拼接,concat 是按轴堆叠。两张结构相同的表上下摞起来用 axis=0(默认):

d1 = pd.DataFrame({"x": [1, 2]})
d2 = pd.DataFrame({"x": [3, 4]})
print(pd.concat([d1, d2]))
print(pd.concat([d1, d2], ignore_index=True))
x
0 1
1 2
0 3
1 4
x
0 1
1 2
2 3
3 4

不加 ignore_index=True 时索引原样保留,出现两个 0、两个 1。这种索引不会立刻报错,但后面 loc[0] 会返回两行(Series 或 DataFrame),依赖唯一索引的代码(set_indexunstack、按索引赋值)全部出问题。堆叠之后接 ignore_index=True,或者 reset_index(drop=True),是省心的默认动作。

左右拼接用 axis=1,按索引对齐:

s1 = pd.DataFrame({"a": [1, 2]})
s2 = pd.DataFrame({"b": [3, 4]})
print(pd.concat([s1, s2], axis=1))
a b
0 1 3
1 2 4

axis=1 的坑在于它按索引对齐,不按位置对齐。两张表行数相同但索引不同(比如一张是筛选过的),结果会出现大量 NaN 而不是报错。行列拼接前确认两边的索引一致,或者都在 reset_index(drop=True) 之后。

df.join()merge 的简化封装,固定按索引连接,写法更短:

left2 = pd.DataFrame({"v": [1, 2]}, index=["s1", "s2"])
right2 = pd.DataFrame({"w": [10, 20]}, index=["s1", "s2"])
print(left2.join(right2))
v w
s1 1 10
s2 2 20

键在索引上时用 join 最省事,尤其是 groupby 之后结果都带索引的情况。要按普通列连接、要控制 validate、要处理同名列,就回到 merge——它的参数更全,行为也更明确。记住一条:拿不准就用 mergejoin 省下的那点字符不值得多一层心智负担。

合并完的表常常还不够干净,缺值、类型不对、宽长格式要调整,接着看 Pandas数据清洗。R 语言里连接操作在 R数据框连接 有对应的讲解,dplyr 的 inner_join()left_join()full_join() 与这里的 how 参数一一对应,连「多对多会膨胀」这个坑都一样,只是 dplyr 会主动打印一条提示。