
简介本资源是CHARLS数据库系列教程第二部分的配套项目源码面向健康经济学、社会学、人口统计学方向的研究者与数据分析学习者重点解决该数据库清洗、拼接与整理流程复杂、缺乏成熟查对系统的问题。压缩包共8个文件约12KB以R脚本为主包含数据清洗与演示分析代码另附示例数据CSV、说明文档、网页预览及项目配置等辅助文件代码量超过100行结构清晰便于按步骤复现。教程以甘油三酯葡萄糖指数与新发糖尿病关系的研究为例完整展示数据下载、清洗与拼接过程并预告后续cox回归、分位数回归与多模型比较等内容。目前已有399人学习。读者可据此掌握可复用的清洗脚本与排错思路为大规模追踪调查数据的规范整理打下基础。1. 从一份“脏得没法回归”的 CHARLS 面板说起CHARLS 数据清洗教程配上项目源码真正要解决的不是“怎么读文件”而是怎么把一份横跨多期、变量名反复改、缺失码五花八门的追踪调查数据整理成能直接跑回归的面板。我见过太多人拿到原始数据后第一反应是pd.read_stata然后df.dropna()结果样本量从两万掉到三千系数符号全反了——这不是数据的问题是清洗逻辑没立住。CHARLS 这类追踪调查的清洗核心就三件事跨期 ID 对齐、缺失码统一、变量跨期映射。这篇笔记按我实际做项目的顺序拆开讲源码结构、每一步的 pandas 操作、参数怎么设、哪里最容易翻车都会落到可复现的代码上。适合正在做微观实证、需要把 CHARLS 从原始文件推到回归样本的研究者新手能跟着跑通熟手能对照检查自己的清洗流程有没有埋雷。2. 先搞清楚 CHARLS 原始数据的结构再动手2.1 为什么不能直接 concat 所有 waveCHARLS 目前公开的主要是 2011 基线、2013、2015、2018、2020 这几期每期一个 Stata 文件但变量命名规则并不统一。2011 年问“是否吸烟”可能叫da0012013 年变成da001_w22015 年又改成da001_w3。如果你直接pd.concat([df2011, df2013])pandas 会按列名对齐结果就是每个变量只有对应那一期的值其他期全是 NaN你以为合并了其实只是把几张表摞在一起。正确的做法是先建一张跨期变量映射表把每一期里代表同一个概念的变量名登记下来再统一重命名。这个映射表是整个清洗流程的“宪法”后面所有操作都依赖它。import pandas as pd import numpy as np # 跨期变量映射key 是统一后的标准名value 是各期原始变量名 var_map { smoke: { 2011: da001, 2013: da001_w2, 2015: da001_w3, 2018: da001_w4, }, age: { 2011: ba002_1, 2013: ba002_1_w2, 2015: ba002_1_w3, 2018: ba002_1_w4, }, edu: { 2011: bd001, 2013: bd001_w2, 2015: bd001_w3, 2018: bd001_w4, }, } def load_wave(path, wave): df pd.read_stata(path, convert_categoricalsFalse) rename_dict {} for std_name, wave_map in var_map.items(): raw_name wave_map.get(wave) if raw_name and raw_name in df.columns: rename_dict[raw_name] std_name df df.rename(columnsrename_dict) df[wave] int(wave) return df这段代码的关键在convert_categoricalsFalse。CHARLS 的 Stata 文件里很多变量带 value label如果让 pandas 自动转成分类变量后面做数值运算时类型会报错而且不同期 label 定义可能不一致自动转换反而制造混乱。先读成原始数值清洗完再按需加标签。var_map的结构设计成“标准名 → 各期原名”好处是新增一期只需要加一行不用改清洗逻辑。实际项目中这个映射表可能有几百行建议单独存成 CSV 或 Python 字典文件不要硬编码在主脚本里。2.2 ID 对齐CHARLS 的个体标识不是一列就够CHARLS 的个体 ID 结构比想象中复杂。2011 年基线用的是ID格式类似xxxx01xxxx其中包含社区、家户、个人三层信息。后续追踪期除了ID还有householdID和communityID。跨期合并时如果只用ID会遇到两个问题一是部分样本后续期 ID 发生了变更比如分家后重新编号二是配偶、子女等非基线样本的 ID 规则不同。常见做法是同时保留ID、householdID和communityID合并时用ID做主键但清洗阶段要检查ID在各期是否唯一、是否有重复。def check_id_uniqueness(df, wave): dup df[df.duplicated(subset[ID], keepFalse)] if len(dup) 0: print(f[wave {wave}] 重复 ID 数量: {len(dup)}) print(dup[[ID, householdID]].head(10)) else: print(f[wave {wave}] ID 唯一样本量 {len(df)}) return dup # 对每一期分别检查 for wave, path in wave_files.items(): df load_wave(path, wave) check_id_uniqueness(df, wave)如果发现重复先别急着drop_duplicates。CHARLS 里重复 ID 往往意味着同一家户内多人共用了一个编号或者追踪期新增了同 ID 的不同个体。这时候要回到问卷逻辑看householdID是否相同如果相同且ID也相同大概率是数据录入问题如果householdID不同说明是不同家户的编号冲突需要结合社区 ID 重新构造唯一键。我一般会构造一个uid communityID householdID ID的复合键虽然长但能避免跨社区编号碰撞。代价是后续合并时所有表都要带这三列内存开销大一些但比合并完发现样本对不上要划算。3. 缺失码、跳转逻辑与变量类型的三重清洗3.1 CHARLS 的缺失码不是 NaN 那么简单CHARLS 原始数据里缺失值有多种编码.a表示不适用跳转跳过、.b表示不知道、.c表示拒答、.d表示其他。在 Stata 里这些是扩展缺失值但读进 pandas 后convert_categoricalsFalse会把它们变成什么实测结果是.a到.d通常变成 NaN但有些期会保留为特定数值比如 -9、-8。如果你不检查就直接fillna(0)等于把“不知道”和“拒答”当成了真实值 0回归结果必然有偏。正确做法是读入后先统计每列的缺失模式区分“真缺失”和“编码缺失”。def missing_profile(df, wave): report [] for col in df.columns: n_nan df[col].isna().sum() n_neg (df[col] 0).sum() if df[col].dtype in [float64, int64] else 0 report.append({ wave: wave, column: col, nan_count: n_nan, negative_count: n_neg, nan_ratio: round(n_nan / len(df), 4), }) return pd.DataFrame(report) # 对每一期生成缺失报告 profiles [] for wave, path in wave_files.items(): df load_wave(path, wave) profiles.append(missing_profile(df, wave)) missing_df pd.concat(profiles, ignore_indexTrue) missing_df.to_csv(missing_profile.csv, indexFalse)拿到这份报告后重点看两类列一是nan_ratio超过 0.5 的说明该变量在该期大部分样本没问跨期分析时要慎重二是negative_count大于 0 的说明存在负值编码缺失需要统一替换成 NaN。def clean_missing_codes(df, neg_codes[-9, -8, -7, -6]): for col in df.select_dtypes(include[np.number]).columns: df[col] df[col].replace(neg_codes, np.nan) return df这里neg_codes的取值要根据实际数据调整。CHARLS 不同期用的负值编码不完全一样有的用 -9 表示不知道有的用 -8。建议先跑一遍df.describe()看最小值分布再决定替换列表。3.2 跳转逻辑导致的“假缺失”怎么处理CHARLS 问卷有大量跳转比如“是否吸烟”答“否”后面“每天吸几支”就跳过不问了。这种情况下.a不适用不是真正的缺失而是逻辑上的“零”或“不适用”。如果你把.a统一当 NaN做吸烟量分析时样本会少掉一大半——因为不吸烟的人本来就不该有吸烟量。处理原则是先根据跳转逻辑构造条件变量再决定缺失码的填充方向。def handle_skip_logic(df): # 吸烟不吸烟的人吸烟量应为 0而不是 NaN if smoke in df.columns and smoke_amount in df.columns: mask_no_smoke df[smoke] 0 df.loc[mask_no_smoke, smoke_amount] df.loc[mask_no_smoke, smoke_amount].fillna(0) # 饮酒同理 if drink in df.columns and drink_freq in df.columns: mask_no_drink df[drink] 0 df.loc[mask_no_drink, drink_freq] df.loc[mask_no_drink, drink_freq].fillna(0) return df这段代码的逻辑是跳转缺失的填充方向取决于前置变量的取值。前置变量说“没有这个行为”后续变量就应该填 0前置变量说“有”后续变量还是 NaN那才是真缺失。实际项目中这种跳转对可能有几十组建议整理成配置表用循环批量处理不要一个个写 if。注意填充前一定要确认前置变量本身没有缺失。如果smoke是 NaN那mask_no_smoke不会命中smoke_amount保持 NaN这是对的。但如果smoke被错误编码成了负值填充逻辑就会失效。3.3 变量类型统一别让字符串混进数值列CHARLS 原始数据里有些变量读进来是 object 类型比如性别可能存成“男/女”教育程度存成“小学/初中/高中”。做回归前必须转成数值。但转换时要注意有些列看起来是数值实际混了非数字字符比如“1.5年”直接astype(float)会报错。def force_numeric(df, exclude_colsNone): if exclude_cols is None: exclude_cols [ID, householdID, communityID] for col in df.columns: if col in exclude_cols: continue if df[col].dtype object: converted pd.to_numeric(df[col], errorscoerce) # 如果转换后非空比例超过 80%认为该列本质是数值 if converted.notna().mean() 0.8: df[col] converted return dferrorscoerce会把无法转换的值变成 NaN然后用notna().mean()判断这一列是否值得保留为数值。阈值 0.8 是经验值如果超过 20% 的值转不了说明这列可能真的是分类文本强行转数值会丢失信息应该走独热编码或有序编码。4. 跨期合并与面板构造的实操细节4.1 用 outer merge 还是 concat取决于你要宽表还是长表跨期合并有两种目标宽表一行一个个体各期变量横向排列和长表一行一个个体-期变量纵向堆叠。做面板回归通常用长表做跨期对比描述统计可能用宽表。长表构造用pd.concat但前提是各期列名已经统一。def build_long_panel(wave_files): frames [] for wave, path in wave_files.items(): df load_wave(path, wave) df clean_missing_codes(df) df handle_skip_logic(df) df force_numeric(df) frames.append(df) panel pd.concat(frames, ignore_indexTrue, sortFalse) panel panel.sort_values([ID, wave]).reset_index(dropTrue) return panelsortFalse在 pandas 新版本里默认就是 False但显式写出来更清楚。ignore_indexTrue重置索引避免各期索引重复导致后续loc出错。排序按ID和wave这是面板数据的基本要求后续做滞后变量、固定效应都依赖这个顺序。宽表构造用merge主键是ID但要注意各期ID可能有增减。def build_wide_panel(wave_files): wide None for wave, path in wave_files.items(): df load_wave(path, wave) df clean_missing_codes(df) df handle_skip_logic(df) df force_numeric(df) # 只保留 ID 和该期特有变量避免列名冲突 cols [ID] [c for c in df.columns if c not in [ID, householdID, communityID, wave]] df df[cols] df df.rename(columns{c: f{c}_w{wave} for c in df.columns if c ! ID}) if wide is None: wide df else: wide wide.merge(df, onID, howouter) return widehowouter保证任何一期出现过的个体都保留。如果某个体只在 2011 年出现后续期变量全是 NaN这是正常的追踪流失不要删。做平衡面板时才需要howinner但 CHARLS 追踪流失率不低平衡面板样本量会大幅缩水一般不建议默认用 inner。4.2 追踪流失与新增样本的标记CHARLS 每期都有新样本加入比如 2013 年新增家户也有样本流失。清洗阶段要生成两个标记变量first_wave记录个体首次出现的期数last_wave记录最后一次出现的期数。这两个变量对后续分析流失偏差很有用。def mark_attrition(panel): first panel.groupby(ID)[wave].min().rename(first_wave) last panel.groupby(ID)[wave].max().rename(last_wave) panel panel.merge(first, onID, howleft) panel panel.merge(last, onID, howleft) panel[balanced] panel[first_wave] panel[last_wave] return panelbalanced为 True 表示该个体只出现过一期False 表示跨期追踪。做固定效应回归时只出现一期的个体对组内估计没有贡献会被自动剔除但提前标记出来可以让你清楚样本损失在哪里。4.3 变量跨期可比性检查同一个名字不代表同一个含义这是最容易被忽略的一步。CHARLS 有些变量名字跨期一样但问卷选项变了。比如教育程度2011 年可能分 8 类2015 年合并成 5 类。如果你直接当连续变量用系数解释会出问题。def check_value_distribution(panel, var): dist panel.groupby(wave)[var].value_counts(normalizeTrue).unstack(fill_value0) print(f变量 {var} 各期取值分布) print(dist.round(3)) return dist # 对关键变量逐一检查 for v in [edu, smoke, age]: check_value_distribution(panel, v)如果发现某期取值类别明显少于其他期说明选项被合并了。处理方式有两种一是按最粗的分类重新编码所有期保证可比二是只保留分类一致的期数做分析。前者损失信息但样本量大后者保留信息但样本量小取决于研究问题。5. 避坑与排查CHARLS 清洗中最容易翻车的五个地方5.1 现象合并后样本量暴涨ID 重复原因不同期的ID编码规则不同2011 年的ID是 10 位2015 年变成 12 位直接 merge 时 pandas 把它们当成不同个体outer merge 后一个个体变成两行。解决合并前先统一 ID 格式。常见做法是提取核心数字部分或者用communityID householdID 个人序号重新构造。构造完再检查唯一性。def normalize_id(df): # 假设 ID 是字符串提取后 8 位作为核心 ID df[ID_core] df[ID].astype(str).str[-8:] return df具体截取规则要看实际 ID 结构不要照搬。关键是合并前对每一期都跑一遍唯一性检查。5.2 现象回归时提示“矩阵奇异”或某个变量被 omitted原因某个分类变量在部分期只有一种取值或者两个变量完全共线。CHARLS 里常见的是年龄和出生年份同时放入或者教育程度和学历年限同时放入。解决清洗阶段就检查变量间的相关系数矩阵对高相关变量对|r| 0.9只保留一个。分类变量检查各期取值数少于 2 个取值的期数要么合并类别要么剔除该期。def check_collinearity(df, vars_list): corr df[vars_list].corr().abs() upper corr.where(np.triu(np.ones(corr.shape), k1).astype(bool)) high_corr [(col, row, upper.loc[row, col]) for col in upper.columns for row in upper.index if upper.loc[row, col] 0.9] return high_corr5.3 现象缺失码替换后均值发生跳变原因负值编码替换成 NaN 后mean()自动跳过 NaN但如果之前负值被当成真实值参与了计算替换前后均值会差很多。更隐蔽的是有些列用 0 表示缺失而不是负值替换逻辑没覆盖到。解决替换前先df.describe()看最小值如果最小值是 0 但该变量逻辑上不可能为 0比如身高说明 0 是缺失码。替换列表要同时包含负值和 0针对特定列。def replace_zero_missing(df, cols): for col in cols: df[col] df[col].replace(0, np.nan) return df # 身高、体重等不可能为 0 的列 replace_zero_missing(panel, [height, weight])5.4 现象跨期合并后分类变量变成浮点数原因某一期该列有 NaNpandas 自动把整列升为 float。后续做groupby或merge时浮点数的 1.0 和整数的 1 不匹配导致合并失败或分组错误。解决合并完成后对本质是整数的列强制转回 Int64pandas 的可空整数类型。def downcast_int(df, cols): for col in cols: if col in df.columns: df[col] df[col].astype(Int64) return df downcast_int(panel, [smoke, drink, edu, gender])Int64而不是int64因为前者支持 NaN后者遇到 NaN 会报错。5.5 现象Stata 读入后中文变量标签丢失原因convert_categoricalsFalse会跳过 value label但变量标签variable label在 pandas 里本来就不保留。如果你需要标签做文档得单独读。解决用pd.read_stata(path, iteratorTrue)先读 metadata或者直接用pyreadstat读标签。import pyreadstat df, meta pyreadstat.read_dta(path) # meta.column_names_to_labels 是变量名到标签的映射 label_map meta.column_names_to_labels这个映射表可以存成 CSV清洗完再按需加回。不影响数值计算但写论文时描述变量要用。6. 从清洗完的面板到可复现的回归样本一个收尾技巧清洗完的面板往往还是“全样本”直接跑回归会包含大量关键变量缺失的观测。我一般会构造一个analysis_sample标记而不是直接dropna()。具体做法是先列出回归方程里用到的所有变量然后逐列检查缺失生成一个缺失计数列最后按阈值筛选。def build_analysis_flag(panel, key_vars, max_missing0): missing_count panel[key_vars].isna().sum(axis1) panel[n_missing] missing_count panel[in_sample] missing_count max_missing print(f全样本: {len(panel)}) print(f分析样本: {panel[in_sample].sum()}) print(f样本损失: {len(panel) - panel[in_sample].sum()}) return panel key_vars [smoke, age, edu, gender, wave] panel build_analysis_flag(panel, key_vars, max_missing0)max_missing0表示所有关键变量都不能缺这是最严格的标准。如果样本损失太大可以放宽到 1 或 2但要在论文里报告敏感性分析。我习惯把n_missing保留在最终数据里这样审稿人问起来可以直接展示缺失分布。另一个技巧是给每个变量加一个缺失标记列比如smoke_missing smoke.isna()回归时把缺失标记作为控制变量放进去。这样做的好处是不用删样本但解释系数时要小心——缺失标记的系数反映的是缺失组和完整组的差异不是因果效应。for v in key_vars: panel[f{v}_missing] panel[v].isna().astype(int)这个操作在 CHARLS 里特别有用因为追踪调查的缺失往往不是随机的——比如健康状况差的人更容易失访缺失标记本身携带信息。把它显式建模比直接删掉更稳妥。最后说一个我踩过的坑有一次清洗完直接panel.to_stata(clean.dta)结果 Stata 打开后中文变量标签全没了而且 Int64 列被转成了 float。后来改成先panel.to_pickle(clean.pkl)存中间结果需要 Stata 格式时再单独转换并且转换前把所有 Int64 列转成 float 或 object。这个习惯帮我省了很多重复清洗的时间。希望帮到你。本文还有配套的精品资源点击获取