Pandas数据合并:merge、join 与 concat
实验数据通常分散在多张表里:一张记录观测值,一张记录样本的分组和处理条件,还有一张是物种、试剂的对照信息。合并(join)就是把它们按共同的键拼成一张分析用的表。这一步不出错的前提是想清楚一件事——键在两边是否唯一。唯一性没确认就合并,行数会悄悄膨胀,后面的统计量全是错的,而且不会报错。
import pandas as pdfrom sklearn.datasets import load_iris
df = load_iris(as_frame=True).framedf.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_len0 1 setosa 5.1 1.41 2 setosa 4.9 1.42 3 setosa 4.7 1.3 species cn_name site0 setosa 山鸢尾 A1 versicolor 变色鸢尾 B2 virginica 维吉尼亚鸢尾 Aobs 里 150 行观测,info 里 3 行物种信息。物种在 info 里是唯一的,在 obs 里重复出现 50 次——这种「一边唯一、一边重复」的结构叫多对一(many-to-one),是最常见的合并场景,也是最安全的。
四种连接方式
Section titled “四种连接方式”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 site0 1 setosa 5.1 1.4 山鸢尾 A1 2 setosa 4.9 1.4 山鸢尾 A2 3 setosa 4.7 1.3 山鸢尾 A合并后行数: 150on="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 site0 1 unknown 5.1 1.4 NaN NaN1 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 site0 setosa 1.0 A1 setosa 2.0 A2 versicolor 3.0 B3 virginica NaN A3 3 -> 4virginica 在左表里没有观测,仍然被保留,v 列填 NaN。真实数据里跑一遍 outer,凡是「半边全是 NaN」的行,就是两张表的键对不上的地方:要么物种表里有的组根本没进实验,要么观测表里出现了物种表没登记的标签。这是核对数据最直接的手段,比人眼比对两份名单快得多。
键名不同:left_on 与 right_on
Section titled “键名不同:left_on 与 right_on”两边的键列名不一样时(比如一边叫 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 dose0 1 setosa 5.1 1.4 setosa 101 2 setosa 4.9 1.4 setosa 10['sample_id', 'species', 'sepal_len', 'petal_len', 'spl', 'dose']后果是两张键列都留下了:species 和 spl 内容完全一样,多一列冗余。表宽一点无所谓,但如果要按位置取列或者 to_csv 给下游用,多出来的列迟早有人踩到。合并后 drop(columns="spl") 一行删掉,养成习惯。
列名撞车:suffixes
Section titled “列名撞车:suffixes”两表有同名但含义不同的列时,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_norm0 1 10 1.51 2 20 2.5_x / _y 这种默认后缀在真实分析里容易埋雷:一天之后你分不清哪个是原始值、哪个是标准化值。只要两边有同名列,就显式写 suffixes,名字里带上含义。写完之后确认一遍列名,别让 value_x 出现在最终结果里。
多对多:行数为什么会膨胀
Section titled “多对多:行数为什么会膨胀”这是最容易出事的一种。两边都不唯一时,合并结果的行数是两边匹配行数的笛卡尔积:
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 y0 1 a p1 1 a q2 1 b p3 1 b q4 2 c rleft: 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_one、many_to_one 这些写法等价)。上面那对多对多的表,只有 "m:m" 不报错——这恰恰说明它有膨胀风险;而真正的多对一(观测表多行对一个物种)写 validate="many_to_one" 就能通过。写脚本时把期望的键关系写进 validate,比事后查行数更可靠——尤其是数据每周更新、这周合并正常、下周源表突然多出一条重复记录这种情况。
concat:不改键,只做堆叠
Section titled “concat:不改键,只做堆叠”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)) x0 11 20 31 4 x0 11 22 33 4不加 ignore_index=True 时索引原样保留,出现两个 0、两个 1。这种索引不会立刻报错,但后面 loc[0] 会返回两行(Series 或 DataFrame),依赖唯一索引的代码(set_index、unstack、按索引赋值)全部出问题。堆叠之后接 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 b0 1 31 2 4axis=1 的坑在于它按索引对齐,不按位置对齐。两张表行数相同但索引不同(比如一张是筛选过的),结果会出现大量 NaN 而不是报错。行列拼接前确认两边的索引一致,或者都在 reset_index(drop=True) 之后。
join:只是 merge 的索引版本
Section titled “join:只是 merge 的索引版本”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 ws1 1 10s2 2 20键在索引上时用 join 最省事,尤其是 groupby 之后结果都带索引的情况。要按普通列连接、要控制 validate、要处理同名列,就回到 merge——它的参数更全,行为也更明确。记住一条:拿不准就用 merge,join 省下的那点字符不值得多一层心智负担。
合并完的表常常还不够干净,缺值、类型不对、宽长格式要调整,接着看 Pandas数据清洗。R 语言里连接操作在 R数据框连接 有对应的讲解,dplyr 的 inner_join()、left_join()、full_join() 与这里的 how 参数一一对应,连「多对多会膨胀」这个坑都一样,只是 dplyr 会主动打印一条提示。