简介:这份CHARLS数据清洗教程项目源码面向健康经济学、社会学与人口统计学方向的研究者及数据分析学习者,聚焦中国大陆45岁及以上人群追踪调查数据的清洗、拼接与整理难题。资源包共8个文件,以R脚本为核心,包含数据清洗、示例分析与环境依赖三类代码文件,另附示例数据CSV、说明文档与网页预览,压缩包约12KB,结构精简、开箱即用。教程以甘油三酯葡萄糖指数与新发糖尿病关系的研究为实例,完整演示从数据下载到初步整理的操作流程,代码量超过100行,并预告后续将延伸至Cox回归、分位数回归与多模型比较等分析方法。目前已有403人学习下载,适合希望掌握CHARLS数据处理技能、为实证研究打基础的读者参考借鉴。
1. CHARLS数据清洗到底在洗什么:从一份“能跑回归”的面板说起
CHARLS 数据清洗教程这个题目,真正要解决的不是“把缺失值删掉”这么简单。CHARLS 是中国健康与养老追踪调查,公开数据以 wave(2011、2013、2015、2018、2020 等)分文件发布,变量名跨 wave 经常不一致,ID 还分 householdID、ID、communityID 多层嵌套。很多人第一次拿到数据,直接 merge 完就跑回归,结果样本量从两万掉到三千,系数符号还反了——这不是模型问题,是清洗没做对。
这篇笔记面向三类人:正在做 CHARLS 实证的硕博生、需要复现文献里“控制变量表”的从业者、以及想把多期追踪数据整理成面板的开发者。我会按“先立住数据逻辑,再动手写脚本”的顺序,把跨 wave 对齐、缺失编码、样本筛选、面板构造这几件事讲透,最后给一套可复用的清洗骨架。你不需要有现成源码,跟着步骤就能搭出自己的 pipeline。
2. 跨 wave 变量对齐:先建字典,再谈合并
2.1 为什么不能直接按变量名 merge
CHARLS 每个 wave 是独立发布的 Stata dta 或 SAS 文件,同一个概念在不同 wave 里变量名可能完全不同。比如“是否吸烟”在 2011 可能是da002,2013 变成da007,2015 又拆成da002_1和da002_2。如果你用merge 1:1 ID using直接拼,Stata 只会保留两边都存在的变量名,语义相同的变量被当成两个,最后要么全缺失,要么错位。
常见做法是:先手工或半自动建一张“变量对照表”(codebook mapping),把每个 wave 的目标变量映射到一个统一的标准名。这张表是整个清洗流程的地基,后面所有 merge、reshape、回归都依赖它。
2.2 用 Python 建一张跨 wave 变量映射表
我一般用 pandas 管理这张映射表,因为它比 Stata 的 label 更灵活,也方便版本控制。下面是一个最小可跑的骨架:
import pandas as pd # 每个 wave 的原始变量名 -> 标准变量名 # 实际项目里这张表要对着各 wave 的问卷 codebook 逐条核对 mapping = pd.DataFrame([ # wave, raw_var, std_var, label (2011, "da002", "smoke", "是否吸烟"), (2013, "da007", "smoke", "是否吸烟"), (2015, "da002_1", "smoke", "是否吸烟"), (2018, "da002", "smoke", "是否吸烟"), (2011, "ba000_w2_3", "age", "年龄"), (2013, "ba000_w2_3", "age", "年龄"), (2015, "ba000_w2_3", "age", "年龄"), (2018, "ba000_w2_3", "age", "年龄"), ], columns=["wave", "raw_var", "std_var", "label"]) # 按 wave 分组,生成 {wave: {raw: std}} 的嵌套字典 rename_map = ( mapping.groupby("wave") .apply(lambda g: dict(zip(g["raw_var"], g["std_var"]))) .to_dict() ) print(rename_map[2011])逻辑说明:mapping表是唯一事实来源,任何变量改名都改这里,不要散落在脚本各处。rename_map把长表转成按 wave 索引的字典,读入每个 wave 后直接df.rename(columns=rename_map[wave])就能统一列名。
参数说明:wave用整数年份,方便排序和筛选;raw_var必须和 dta 里的列名完全一致,大小写敏感;std_var建议用英文小写下划线,避免中文列名在 Stata 里出问题。如果某个 wave 没有这个变量,就不要写进表里,后面 merge 时用how="outer"保留信息。
2.3 读入与统一列名的完整流程
建好映射后,读入每个 wave 并统一列名:
import pandas as pd waves = [2011, 2013, 2015, 2018] frames = [] for w in waves: # CHARLS 常见是 Stata dta,用 read_stata df = pd.read_stata(f"data/charls_{w}.dta", convert_categoricals=False) df = df.rename(columns=rename_map.get(w, {})) df["wave"] = w # 只保留标准变量 + ID + wave,减少内存 keep = ["ID", "householdID", "communityID", "wave"] + \ [c for c in df.columns if c in mapping["std_var"].unique()] frames.append(df[keep]) panel = pd.concat(frames, ignore_index=True, sort=False) print(panel.shape, panel["wave"].value_counts())逻辑说明:convert_categoricals=False很关键,否则 Stata 的 value label 会被转成字符串,后面做数值运算会翻车。pd.concat按行堆叠,缺失的列自动填 NaN,这正是我们要的——不同 wave 没有的变量就是缺失,不要用 0 填充。
参数说明:keep列表里同时保留三层 ID,因为 CHARLS 的个体可能跨 household 迁移,后面做固定效应时要选对 ID 层级。sort=False避免 pandas 对列名排序,保持可读性。
3. 缺失值编码与样本筛选:别把“拒答”当成“没有”
3.1 CHARLS 的缺失不是一种缺失
CHARLS 的缺失至少分四类:不适用(skip)、拒答(refuse)、不知道(don't know)、真正缺失(missing)。在原始 dta 里,它们可能被编码成.、.a、.b、-9、999等。如果你统一dropna(),会把“不适用”和“拒答”一起删掉,样本量断崖式下跌,而且删掉的往往是非随机群体,回归结果直接有偏。
常见做法是:先按 wave 和变量查 codebook,把特殊缺失码映射成 pandas 的NA,同时保留一个缺失类型标记列,供后面做敏感性分析。
3.2 用一张缺失码表统一处理
import numpy as np import pandas as pd # 特殊缺失码 -> 统一标记 # 实际项目里要按变量类型分别处理,这里给通用骨架 missing_codes = { -9: "not_applicable", -8: "dont_know", -7: "refuse", 999: "missing", 9999: "missing", } def clean_missing(series, codes=missing_codes): # 先记录缺失类型 flag = series.map(codes).fillna("valid") # 再把特殊码替换成 NA cleaned = series.replace(list(codes.keys()), np.nan) return cleaned, flag # 对 smoke 变量做示范 panel["smoke_clean"], panel["smoke_flag"] = clean_missing(panel["smoke"]) print(panel["smoke_flag"].value_counts())逻辑说明:flag列记录每个观测的缺失类型,后面可以统计“拒答比例是否随 wave 变化”,这是判断数据质量的重要指标。replace只替换特殊码,不影响正常值。
参数说明:missing_codes的键必须是数值,因为 dta 读进来是数值型;如果某变量是字符串,要先转数值或单独处理。999和9999同时映射到 missing,是因为不同 wave 的缺失码位数不同。
3.3 样本筛选的三个必查条件
清洗完缺失后,不要急着跑回归。先做三件事:
第一,查 ID 唯一性。同一个 wave 内ID应该唯一,如果重复,说明 merge 出了笛卡尔积。用panel.duplicated(subset=["ID","wave"]).sum()检查。
第二,查年龄合理性。CHARLS 受访者一般 45 岁以上,如果 age 出现 0 或 200,说明变量映射错了。
第三,查面板平衡性。用panel.groupby("ID")["wave"].nunique()看每个人出现几次,决定用平衡面板还是非平衡面板。
# ID 唯一性 dup = panel.duplicated(subset=["ID", "wave"]).sum() print("重复 ID-wave:", dup) # 年龄范围 print(panel["age"].describe()) # 面板平衡性 waves_per_id = panel.groupby("ID")["wave"].nunique() print(waves_per_id.value_counts())逻辑说明:这三步是“后悔药”,做完再往下走,能省掉后面大量返工。describe()看 min/max 比看均值更有效,异常值一眼就能发现。
参数说明:如果dup大于 0,优先检查 merge 键是否选错,而不是直接drop_duplicates,因为重复可能意味着数据本身有问题。
4. 面板构造与变量衍生:从长表到能跑固定效应的宽表
4.1 长表 vs 宽表:什么时候用哪个
清洗完的长表(long format)适合做描述统计和跨 wave 比较,但跑个体固定效应时,Stata 的xtset和 Python 的linearmodels都要求宽表或特定长表结构。我一般保留两份:一份长表做清洗和核查,一份宽表做回归。
宽表的构造用pivot,但要注意:不是所有变量都适合 pivot,时不变变量(如性别、出生年)只需要保留一份。
4.2 用 pivot 构造宽表并衍生关键变量
import pandas as pd # 时变变量:每个 wave 一个值 time_varying = ["smoke_clean", "age"] # 时不变变量:取第一个非空值 time_invariant = ["gender", "birth_year"] # 先构造时变部分的宽表 wide_tv = panel.pivot(index="ID", columns="wave", values=time_varying) wide_tv.columns = [f"{var}_{w}" for var, w in wide_tv.columns] wide_tv = wide_tv.reset_index() # 时不变部分去重 wide_ti = ( panel.sort_values("wave") .groupby("ID")[time_invariant] .first() .reset_index() ) wide = wide_tv.merge(wide_ti, on="ID", how="left") print(wide.head())逻辑说明:pivot后列名是 MultiIndex,用列表推导拍平成smoke_clean_2011这种可读格式。时不变变量用groupby().first()取第一个非空,避免重复。
参数说明:index="ID"要求 ID 唯一,如果前面查重没做,这里会报错。how="left"保留所有个体,即使某些 wave 缺失。
4.3 衍生变量:年龄队列与教育年限
CHARLS 的教育变量通常是分类的,做回归时需要转成年限。常见映射:小学 6 年、初中 9 年、高中 12 年、大专 15 年、本科 16 年、硕士 19 年。这个映射没有唯一标准,要在论文里写清楚。
# 教育年限映射(示例,实际按 codebook 调整) edu_map = { "小学": 6, "初中": 9, "高中": 12, "大专": 15, "本科": 16, "硕士": 19 } wide["edu_year"] = wide["education"].map(edu_map) # 年龄队列:按出生年分组 wide["birth_cohort"] = pd.cut( wide["birth_year"], bins=[1900, 1930, 1940, 1950, 1960, 1970, 2000], labels=["<=1930", "1931-1940", "1941-1950", "1951-1960", "1961-1970", ">1970"] ) print(wide[["edu_year", "birth_cohort"]].describe(include="all"))逻辑说明:map对未匹配值返回 NaN,正好提示你哪些教育类别没覆盖。pd.cut生成有序分类,回归时可以转成哑变量。
参数说明:bins的左开右闭要统一,避免边界值落错组。labels用字符串方便阅读,但回归前要get_dummies。
5. 避坑与排查:清洗 CHARLS 最容易翻车的五个地方
5.1 现象:merge 后样本量暴涨
原因:merge 键不唯一,比如用householdID而不是ID,一个家庭多个成员导致笛卡尔积。
解决:merge 前先df.groupby(key).size().max()确认唯一性,或者用validate="1:1"参数让 pandas 直接报错。
5.2 现象:年龄出现负数或超过 120
原因:变量映射错位,把出生年当成了年龄,或者缺失码-9没被替换。
解决:在clean_missing之后加一步范围检查,panel.loc[~panel["age"].between(18, 120), "age"] = np.nan,并打印被置空的记录数。
5.3 现象:固定效应回归系数符号和文献相反
原因:面板没有按 ID 和 wave 排序,xtset或PanelData把时间顺序搞反了。
解决:回归前强制panel = panel.sort_values(["ID", "wave"]),并检查panel.groupby("ID")["wave"].apply(lambda x: x.is_monotonic_increasing).all()。
5.4 现象:不同 wave 的同一个变量均值差异巨大
原因:问卷改版导致变量定义变了,比如“吸烟”从“现在吸”变成“曾经吸”。
解决:对照各 wave codebook,在映射表里加一列definition_note,定义不一致的变量要么拆分,要么只保留定义一致的 wave。
5.5 现象:Stata 读入后中文乱码
原因:dta 文件编码和 Stata 版本不匹配。
解决:用 pandas 读入后df.to_stata("clean.dta", write_index=False, version=118)重新导出,version 118 支持 Unicode。
6. 进阶技巧:把清洗流程做成可复现的 pipeline
6.1 用配置文件管理 wave 和变量
把 wave 列表、映射表路径、输出路径写进 YAML,脚本只读配置。这样换一批数据或加一个新 wave,不用改代码。
# config.yaml waves: [2011, 2013, 2015, 2018] mapping: "config/var_mapping.csv" raw_dir: "data/raw" out_dir: "data/clean" id_cols: ["ID", "householdID", "communityID"]import yaml with open("config.yaml", encoding="utf-8") as f: cfg = yaml.safe_load(f) print(cfg["waves"], cfg["id_cols"])逻辑说明:配置和代码分离是复现性的基础。审稿人问“你怎么处理的 2015 年”,你直接指配置文件,不用翻脚本。
参数说明:encoding="utf-8"避免中文路径在 Windows 上出问题。id_cols集中管理,后面 merge 和查重都引用它。
6.2 加一层数据质量报告
每次清洗完,自动生成一份 markdown 报告:每个 wave 的样本量、关键变量缺失率、异常值数量。这份报告不放进论文,但能帮你快速定位问题。
def quality_report(df, vars_check): rows = [] for w, g in df.groupby("wave"): for v in vars_check: rows.append({ "wave": w, "var": v, "n": len(g), "missing_rate": g[v].isna().mean().round(4), "mean": g[v].mean().round(3) if g[v].dtype != "object" else None, }) return pd.DataFrame(rows) report = quality_report(panel, ["smoke_clean", "age", "edu_year"]) report.to_csv("output/quality_report.csv", index=False) print(report.head(10))逻辑说明:missing_rate按 wave 和变量交叉统计,能一眼看出哪个 wave 的哪个变量缺失异常。mean只对数值型计算,避免字符串报错。
参数说明:vars_check列表按研究问题选,不要把所有变量都塞进去,报告太长反而没人看。
6.3 一个我踩过的坑:不要过早删除缺失样本
早期我做 CHARLS 清洗时,习惯在 merge 后直接dropna(),结果样本从 18000 掉到 6000,回归显著性和文献对不上。后来改成:先保留所有样本,在回归时按模型需要筛选,并在论文里报告“因缺失剔除的样本量”。这样既透明,也方便做稳健性检验。
具体做法是:在宽表里加一列sample_flag,标记每个观测是否进入基准回归,而不是物理删除行。这样你可以随时切换样本定义,不用重跑整个清洗流程。
# 标记而非删除 wide["sample_flag"] = ( wide["age_2011"].notna() & wide["smoke_clean_2011"].notna() & wide["edu_year"].notna() ) print(wide["sample_flag"].value_counts())逻辑说明:sample_flag是布尔列,回归时用wide[wide["sample_flag"]]筛选。这样原始数据完整保留,任何样本定义变化都可追溯。
参数说明:条件里的变量按你的基准模型选,不要把所有变量都放进去,否则样本量会过小。建议先跑一个“最小样本”模型,再逐步加控制变量,观察样本量变化。
这套流程我用了几年,最大的体会是:CHARLS 清洗的难点不在代码,而在“定义清楚每个变量的含义和缺失类型”。代码可以复制,定义必须自己对着 codebook 一条条核。希望帮到你。
本文还有配套的精品资源,点击获取