做数据分析,很大一部分时间花在"把散落各处的数据拼到一起"上。这学期做了两个分析作业,都涉及多源数据合并,但用的策略完全不同——一个用 inner 一个用 left,一个用复合键一个用双列键。

这篇笔记把两种做法放在一起对比,顺便记下合并时踩到的坑。

为什么一定要合并

先说清楚动机。要分析"城市经济发展和空气质量的关系",需要两类数据:

  • 经济指标:GDP、人均 GDP、第三产业占比、人口密度、汽车保有量……
  • 环境指标:AQI、PM2.5、PM10、SO2、NO2、O3、CO、优良天数比例

这两类数据来自不同的来源——经济数据来自统计年鉴,空气质量数据来自环保部门的数据集。它们在不同的文件里,但分析时必须对齐到同一张表上,否则没法算"GDP 高的城市空气质量是不是更差"。

合并就是把两张表按某个共同的"键"拼起来。听起来简单,但键怎么选、用哪种合并方式,直接决定了结果对不对。

做法一:复合键 + inner join

城市项目里,两个 CSV 各有 20 行,合并的代码只有一行:

df_merged = df_economy.merge(df_air, on=['城市', '区域'], how='inner')

三个参数值得逐个说。

键是 ['城市', '区域'] 而不是只有 '城市'

为什么用两列做键?乍看"城市"单独做主键就够了——北京、上海、广州,每组只有一个。

但用复合键有额外的好处:它能校验数据的一致性。如果经济表里"杭州"标的是东部、而空气表里"杭州"标成了中部,用单键合并不会报任何错误,合并后的行会同时带上两个矛盾的区域标签,后续按区域分组统计就全错了。

用复合键的话,这种情况会因为匹配不上而被排除——错误立刻暴露出来,而不是悄悄污染结果。

这是一个通用的思路:当你知道两个字段应该是一致的,就把它们都放进键里,让数据自己报错。

how='inner':只保留两边都有的

inner join 的意思是取交集——只有当两个表里都能找到匹配的行,才保留下来。

这个选择在本项目里是合理的:20 个城市两边都齐全,用 inner 和用 left 结果一样。但语义上 inner 更准确——"我要分析的是既有经济数据又有空气质量数据的城市"。如果某个城市只有污染数据没有经济数据,那它本来就参与不了这个分析,保留它反而要处理一堆 NaN。

合并完顺手算了两个派生指标

df_merged['人均汽车保有量_辆每千人'] = (
    df_merged['汽车保有量_万辆'] * 10000 / df_merged['年末户籍人口_万人']
).round(0)

df_merged['经济密度_GDP每km2'] = (
    df_merged['GDP_亿元'] / (df_merged['年末户籍人口_万人'] / df_merged['人口密度_人每km2'] * 100)
).round(2)

第一行是把"汽车保有量(万辆)"换算成人均,单位统一到"辆/千人"。单位换算在数据分析里是高频操作——原始数据的单位往往不适合直接比较,北京 657 万辆和南昌 130 万辆放一起看着差距很大,但北京人口多得多,人均之后差距就完全不同了。

第二行更有意思:它用人口和人口密度反推出了城市面积。因为 人口密度 = 人口 / 面积,所以 面积 = 人口 / 人口密度。拿到面积之后再算 GDP 密度。

这是数据处理里常见的迂回:缺少某个指标时,看看能不能用已有的指标推导出来

做法二:双列键 + 三次 left join

全球互联网项目的数据结构不一样——4 个 CSV,每个都是一个指标的多国多年面板数据:

文件规模年份范围
互联网使用率7,119 行 / 261 国1990–2019
宽带渗透率4,175 行 / 255 国1998–2019
移动订阅量11,895 行 / 262 国1960–2019
互联网用户数4,536 行 / 193 国1990–2017

合并方式是以使用率表为主表,连续做三次 left join

df = df_share.merge(df_broadband[['Entity', 'Year', 'Broadband']],
                    on=['Entity', 'Year'], how='left')
df = df.merge(df_mobile[['Entity', 'Year', 'Mobile']],
              on=['Entity', 'Year'], how='left')
df = df.merge(df_users[['Entity', 'Year', 'NetUsers']],
              on=['Entity', 'Year'], how='left')

这里的键是 ['Entity', 'Year']——国家和年份两列。因为这是面板数据,同一个国家有很多年的记录,光用国家做键会一对多,合并后行数会爆炸。

必须用"国家+年份"才能唯一定位到一个数据点。

为什么这次用 left 而不是 inner

和城市项目相反,这里用 left 保留左表(使用率表)的所有行,右表匹配不上就填 NaN。

原因是四个数据集的覆盖范围不一样

  • 国家数不同:使用率 261 国,用户数只有 193 国
  • 年份范围不同:移动订阅量从 1960 年就有,互联网使用率从 1990 才开始

如果直接用 inner,会丢掉大量行——而且丢掉的往往是有价值的记录(比如某个国家有使用率数据但缺宽带数据)。

用 left 先把所有行保住,把"哪些数据缺失"这件事显式地变成 NaN,而不是静默地丢掉整行。然后在下游按需要处理:要么填值,要么在分析某个特定指标时再过滤。

这就是两种合并方式的核心区别:

innerleft
保留什么两边都有的行左表全部的行
匹配不上整行丢弃填 NaN
适用场景键的完整性有保证,缺数据的行本来就不参与分析各源覆盖范围不同,想保留缺失信息

只取需要的列

注意 df_broadband[['Entity', 'Year', 'Broadband']] 这个写法——合并前先切片,只保留要用的三列

这不是可有可无的优化。原始表可能有很多列,全带进来一是浪费内存,二是列名冲突时 pandas 会自动加 _x/_y 后缀,后面引用起来很麻烦。先切干净,合并后的表就是清爽的。

合并之后的清理

拼完表还没完。全球项目的清洗流程里有两步专门处理合并带来的问题:

剔除聚合实体

# 用 Code(ISO 三字母码)过滤掉聚合实体
# 剔除 "World"、"Asia"、"Europe"、"High income" 等

这是个很容易漏的坑。OWID 这类数据集的 Entity 列里,除了国家还有地区汇总和收入分组——"World"(全球)、"Asia"(亚洲)、"High income"(高收入国家)等等。

这些行如果不剔掉,会带来严重问题:算"平均互联网使用率"时,"World"那一行本身就是所有国家的平均,再和其他国家一起平均一次,等于重复计算。

识别方法是看 Code 列:真实的 ISO 三字母国家码(如 CHN、USA),聚合实体的 Code 字段是空的。

处理缺失值

# 剔除 4 指标任一缺失的行,最终 184 个国家 × 8 列,完整度 100%

left join 产生的 NaN 在这里被清理掉——因为后续要做相关性分析,任何一个指标缺失都会让这一行无法参与计算。

清理后剩 184 个国家。这个数字值得注意:原始的使用率表有 261 个国家,剔除聚合实体再要求四个指标齐全之后只剩 184 个。数据量从 261 降到 184,是合并策略必须付出的代价——想要四个指标都齐,就得接受覆盖国家变少。

另一个选择是放宽要求,用两个指标做分析,能覆盖更多国家。这是分析目标决定的取舍,没有标准答案。

还有一点没想清楚

两个项目做下来,合并本身的操作已经很熟了——键怎么选、用 inner 还是 left、合并完怎么检查,这些都能总结成套路。

但有个问题我一直没找到好的答案:合并之后丢掉了多少数据,到什么程度算"可以接受"?

全球互联网项目里,原始使用率表有 261 个实体,要求四个指标齐全之后只剩 184 个国家,少了将近三成。我当时的处理是直接砍掉——因为后续要做四变量相关性分析,缺任何一个都算不了。但如果分析目标换成"只看宽带和使用率的关系",能保住的国家会多得多。

也就是说,"保留多少行"这件事是被分析目标反向决定的,不存在一个标准答案。先想清楚要回答什么问题,再决定能接受多大的数据损失。

这个顺序我一开始是反着来的——先把数据清洗干净,之后才发现剩下的样本对某些分析已经不够用了,只能回去重新跑一遍合并。下次做类似的事,我会先把要问的问题列出来,再倒推需要保留哪些字段和哪些行。