
简介这份资源是CHARLS数据库系列教程第二部分的配套项目源码面向健康经济学、社会学、人口统计学方向的研究者与数据分析学习者重点解决中国45岁及以上人群追踪调查数据清洗、拼接与整理流程复杂、缺乏成熟查对系统的问题。压缩包共8个文件约12KB以R脚本为主包含数据清洗、演示分析等核心代码另附示例数据CSV、说明文档与网页预览文件代码量超过100行结构清晰便于按步骤复现。教程以甘油三酯葡萄糖指数与新发糖尿病关系的研究为例完整展示数据下载、清洗与拼接过程并预告后续cox回归、分位数回归、多模型比较等内容。目前已有399人学习适合希望掌握CHARLS数据处理技能、提升大规模数据集整理效率的读者参考。1. 一份 CHARLS 数据清洗教程为什么值得你花时间啃CHARLS 数据清洗教程配上项目源码这个组合在公开渠道里其实不多见。CHARLS 是国内少有的、面向中老年家庭与个人的大型追踪调查数据问卷模块多、年份跨度长、变量命名规则复杂很多人拿到原始压缩包的第一反应是「这怎么下手」。我见过太多人卡在第一步把几个年份的 .dta 文件往 pandas 里一读发现 ID 对不上、缺失值编码五花八门、变量名一年一个样然后就放弃了。这份教程加源码的价值不在于它教了你多高深的算法而在于它把「从原始数据到可分析面板」这条脏活路径完整走了一遍。适合谁适合正在做老龄健康、劳动经济、家庭追踪方向实证研究且已经会一点 Python 或 Stata、但被 CHARLS 的清洗环节卡住的人。下面我按自己实际复现过的流程把选型、代码、参数和坑一条条拆开讲。2. 先搞清楚 CHARLS 的数据结构再动手模块、ID 与年份对齐2.1 CHARLS 的模块化问卷决定了清洗顺序CHARLS 不是一张大表而是按问卷模块拆成多个文件人口学背景、家庭结构、健康状况、认知功能、工作与退休、收入与资产、医疗保险等。每个模块一个数据文件年份之间还会增删题目。这意味着你不能「先合并再清洗」正确顺序是「先按模块清洗 → 再按个体 ID 纵向拼接 → 最后横向合并模块」。我一般会先建一个目录规范把原始数据、中间产物、最终面板分开避免改着改着把原始文件覆盖了# 目录结构建议 charls_project/ ├── raw/ # 原始 .dta / .sav只读永不修改 ├── interim/ # 单年份单模块清洗后的中间文件 ├── processed/ # 纵向拼接后的面板数据 └── code/ # 清洗脚本这个结构看着简单但它是后面所有可复现性的基础。raw 目录只读这一条是我踩过坑之后立的规矩——有一次我直接在原始文件上做缺失值替换结果想回溯原始编码时已经没有后悔药了。2.2 个体 ID 与家庭 ID 的层级关系CHARLS 的核心标识有两类个体 ID通常形如ID或householdID_personID的组合和家庭 ID。跨年份追踪靠的是个体 ID但不同年份的 ID 变量名可能不同比如某年叫ID另一年叫pid。清洗第一步就是统一 ID 命名。import pandas as pd def load_and_standardize_id(path, id_col_candidates): 读取单模块数据并统一 ID 列名 df pd.read_stata(path, convert_categoricalsFalse) # 在候选列名里找到实际存在的 ID 列 id_col next((c for c in id_col_candidates if c in df.columns), None) if id_col is None: raise ValueError(f未找到 ID 列候选{id_col_candidates}) df df.rename(columns{id_col: pid}) # 统一为字符串避免前导零丢失 df[pid] df[pid].astype(str).str.strip() return df逻辑说明convert_categoricalsFalse是关键参数。CHARLS 的 .dta 文件里很多分类变量带 Stata 值标签如果让 pandas 自动转换你会拿到字符串标签而不是数值编码后续做回归或合并时非常麻烦。统一转成字符串再 strip是为了处理某些年份 ID 带空格或前导零的情况——前导零丢失会导致跨年匹配大面积失败这个坑很隐蔽现象是「明明应该是同一个人合并后却对不上」。2.3 年份对齐变量名映射表怎么建跨年份清洗最耗时的不是写代码是建变量映射表。CHARLS 同一个概念在不同年份可能叫da001、da002也可能换了前缀。我的做法是维护一张 CSV 映射表而不是把映射硬编码进脚本概念2011 变量2013 变量2015 变量统一名性别ba0001ba0001ba0001gender出生年ba0002ba0002ba0002birth_year自评健康da001da002da003self_health婚姻状态be001be001be002maritaldef apply_var_map(df, year, var_map_df): 按年份把原始变量名重命名为统一名 sub var_map_df[[concept, f{year}_var]].dropna() rename_dict dict(zip(sub[f{year}_var], sub[concept])) # 只重命名实际存在的列避免 KeyError rename_dict {k: v for k, v in rename_dict.items() if k in df.columns} return df.rename(columnsrename_dict)参数说明映射表用「概念 各年份变量名」的宽表结构好处是新增年份只需加一列不用改代码逻辑。dropna是为了处理某些概念在某年没问的情况。这里要注意映射表本身需要你对着各年份问卷核对没有捷径但一旦建好后面所有年份复用边际成本极低。3. 缺失值、异常值与分类变量CHARLS 清洗里最容易翻车的三件事3.1 CHARLS 的缺失值编码不是一种这是新手最容易翻车的地方。CHARLS 里缺失值至少有三种编码.Stata 系统缺失、-9不适用、-8不知道/拒答。如果你直接dropna()只会删掉第一种后两种会被当成真实数值参与计算均值、回归系数全歪。import numpy as np # CHARLS 常见特殊编码 SPECIAL_CODES { -9: np.nan, # 不适用 -8: np.nan, # 不知道/拒答 -7: np.nan, # 其他特殊含义按问卷说明调整 } def clean_special_codes(df, exclude_colsNone): 把特殊编码统一替换为 NaNID 列除外 exclude_cols exclude_cols or [pid] for col in df.columns: if col in exclude_cols: continue if pd.api.types.is_numeric_dtype(df[col]): df[col] df[col].replace(SPECIAL_CODES) return df逻辑说明只对数值列做替换因为字符串列里-9可能是合法文本。exclude_cols把 ID 排除防止 ID 恰好等于 -9 时被误伤虽然概率低但防御性编程没坏处。替换后建议统计每列缺失率缺失率超过 30% 的变量要慎重使用CHARLS 某些敏感模块的拒答率确实偏高。3.2 异常值年龄、收入、BMI 的合理区间清洗完缺失值下一步是异常值。CHARLS 里常见的异常包括年龄超过 120、出生年晚于调查年、收入出现极端值、身高体重导致 BMI 离谱。我的做法是先用分位数看一眼分布再定阈值而不是拍脑袋。def flag_outliers(df, col, low, high): 标记超出合理区间的记录不直接删除 mask (df[col] low) | (df[col] high) print(f{col}: 异常 {mask.sum()} 条占比 {mask.mean():.2%}) return mask # 示例年龄合理区间 18-120 age_outlier flag_outliers(df, age, 18, 120)参数说明这里刻意「只标记不删除」。原因是我更愿意把异常值处理决策留到分析阶段——有些异常可能是真实的极端个案直接删会引入选择偏差。标记出来后可以单独导出核对或者在做稳健性检验时对比删与不删的结果差异。BMI 的合理区间一般取 10-60超出的大多是身高体重录入错误。3.3 分类变量值标签与数值编码的取舍CHARLS 的分类变量在 Stata 里带值标签比如性别 1男 2女。用 pandas 读取时convert_categoricalsTrue会给你「男」「女」字符串False会给你 1、2。选哪个取决于你的下游工具如果后续用 statsmodels 做回归数值编码更方便如果要出描述性统计表字符串更直观。我的习惯是清洗阶段保留数值编码同时单独存一份值标签字典需要展示时再映射回去def get_value_labels(path, var): 从 Stata 文件提取某变量的值标签映射 import pandas as pd df pd.read_stata(path, convert_categoricalsTrue) if hasattr(df[var], cat): return dict(enumerate(df[var].cat.categories)) return {}这样做的代价是多写几行代码收益是清洗后的数据保持「薄」——只有数值不掺杂展示逻辑后续合并、计算都不会因为字符串类型出问题。4. 用 pandas 把多期 CHARLS 拼成面板合并、去重与平衡性检查4.1 纵向拼接同一模块跨年份 concat单模块清洗完先做纵向拼接。这里的关键是保证各年份列名已经统一且都保留了pid和year两列。def stack_years(file_list, year_list): 把同一模块的多个年份纵向拼接 frames [] for path, yr in zip(file_list, year_list): df pd.read_stata(path, convert_categoricalsFalse) df[year] yr frames.append(df) panel pd.concat(frames, ignore_indexTrue, sortFalse) return panel逻辑说明sortFalse避免 pandas 对列名排序保持原始顺序便于核对。ignore_indexTrue重建索引防止不同年份索引重复导致后续 merge 出错。拼接后立刻检查pid year是否唯一这是面板数据的基本要求。4.2 横向合并模块间 merge 的三种连接方式模块之间靠pid year合并。用哪种 join 取决于你的研究问题inner只保留所有模块都有的样本left保留主模块全部样本。我一般以人口学模块为主表做 left join这样不会因为某个模块缺失而丢掉个体。def merge_modules(base, others, on[pid, year], howleft): 以 base 为主表逐个合并其他模块 result base for name, df in others.items(): before len(result) result result.merge(df, onon, howhow, suffixes(, f_{name})) after len(result) if after ! before: print(f合并 {name} 后行数变化{before} - {after}检查是否有重复键) return result参数说明suffixes用于处理重名列给来自不同模块的同名列加后缀。行数变化检查是必须的——如果 left join 后行数变多说明右表在pid year上有重复这会导致后续所有分析结果膨胀。发现重复要先回右表去重而不是在这里硬合并。4.3 平衡面板与非平衡面板怎么选、怎么查面板数据分平衡每个个体每期都有和非平衡允许缺失期。CHARLS 因为追踪流失天然是非平衡的。是否强制平衡取决于方法固定效应模型对非平衡面板容忍度较高但某些动态面板方法要求平衡。def panel_balance_report(df, id_colpid, time_colyear): 输出面板平衡性报告 counts df.groupby(id_col)[time_col].nunique() total_years df[time_col].nunique() balanced (counts total_years).sum() print(f总个体数{len(counts)}) print(f平衡个体数{balanced}占比 {balanced/len(counts):.2%}) print(f平均追踪期数{counts.mean():.2f}) return counts这个报告能帮你快速判断数据质量。如果平衡个体占比过低比如低于 30%就要考虑追踪流失是否与你的研究变量相关必要时做流失偏差检验。5. 避坑与排查CHARLS 清洗里那些让人怀疑人生的瞬间5.1 现象合并后样本量暴涨原因右表键重复解决先去重再合并这是最高频的坑。现象是 left join 之后行数比左表还多。原因几乎总是右表在pid year上不唯一比如同一人在同一年有两条记录可能是问卷重复录入或家庭成员混淆。解决办法是在合并前对右表做键唯一性检查def check_key_unique(df, keys): dup df.duplicated(subsetkeys, keepFalse) if dup.any(): print(f发现 {dup.sum()} 条重复键记录) return df[dup].sort_values(keys) return None拿到重复记录后要么按业务规则保留一条要么聚合。千万别直接drop_duplicates()了事因为你不知道丢掉的是不是重要信息。5.2 现象跨年匹配率极低原因ID 类型或前导零不一致解决统一转字符串并 strip现象是 2011 和 2013 的 ID 匹配率只有一半。原因通常是某年 ID 是数值型、某年是字符串型或者字符串带前导零而数值型丢了零。解决办法就是前面说的统一astype(str).str.strip()。另外要检查是否有全角空格或不可见字符可以用str.replace(r\s, , regexTrue)彻底清理。5.3 现象回归系数方向诡异原因缺失值编码未处理解决全局扫描特殊编码现象是某个健康指标的回归系数符号和文献相反。排查后发现该变量里-8不知道没被替换成 NaN被当成真实数值参与计算把均值拉偏了。解决办法是清洗初期就做一次全局特殊编码扫描def scan_special_codes(df, codes(-9, -8, -7)): 扫描各列中特殊编码出现次数 report {} for col in df.select_dtypes(include[np.number]).columns: cnt df[col].isin(codes).sum() if cnt 0: report[col] cnt return report这个报告能让你一眼看出哪些变量「脏」优先处理。5.4 现象内存爆掉原因一次性读入所有年份所有模块解决分模块流式处理CHARLS 全量数据加上多模块多期一次性读入很容易吃满内存。解决办法是分模块处理每个模块清洗完立刻落盘到 interim再处理下一个。pandas 读 Stata 时可以指定columns参数只读需要的列进一步降内存。5.5 现象值标签丢失导致描述统计全是数字原因convert_categoricals 设置不当解决清洗与展示分离现象是出描述性统计表时性别显示 1、2 而不是男、女。这不是错误是清洗阶段刻意保留数值编码的结果。解决办法是维护值标签字典展示时映射。别为了图省事在清洗阶段就转字符串后面回归会哭。6. 从清洗脚本到可复用管线参数化、日志与结果校验6.1 把年份和模块做成配置而不是硬编码清洗脚本写死年份和路径换个数据集就废了。我的做法是用一个 YAML 或 JSON 配置描述输入输出import yaml def load_config(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) # config.yaml 示例结构 # years: [2011, 2013, 2015] # modules: # demographic: {id_candidates: [ID, pid], out: demographic_panel.parquet}这样新增年份只改配置脚本逻辑不动。参数化的另一个好处是方便做敏感性分析——换一套缺失值阈值改配置重跑即可。6.2 每一步落盘用 parquet别用 csv中间产物我强烈建议用 parquet 而不是 csv。原因有三parquet 保留数据类型不会出现读回来 ID 变数值、前导零丢失的问题压缩率高多期面板体积小很多读写速度快。csv 唯一的好处是可读但清洗中间产物不需要人眼读。def save_interim(df, path): 中间产物统一落盘为 parquet df.to_parquet(path, indexFalse) print(f已保存 {path}形状 {df.shape})6.3 结果校验清洗完必须过的三道检查清洗完不能直接进分析我一般会跑三道校验。第一道是键唯一性pid year必须唯一。第二道是取值范围关键变量落在合理区间。第三道是跨年一致性同一个体性别、出生年不随年份变化。def validate_panel(df): 面板数据三道校验 # 1. 键唯一 assert not df.duplicated(subset[pid, year]).any(), 键重复 # 2. 性别跨年一致 gender_nunique df.groupby(pid)[gender].nunique() assert (gender_nunique 1).all(), 存在性别跨年不一致的个体 # 3. 出生年跨年一致 birth_nunique df.groupby(pid)[birth_year].nunique() assert (birth_nunique 1).all(), 存在出生年跨年不一致的个体 print(校验通过)这三道检查能拦住大部分低级错误。性别和出生年跨年不一致往往意味着 ID 匹配错了把两个人当成一个人了这种错误如果不查后面所有追踪分析都是错的。6.4 一个具体技巧用哈希指纹追踪清洗版本清洗管线迭代多次后你很容易忘记当前结果对应哪版脚本。我的习惯是每次产出最终面板时算一个数据指纹比如对关键列做哈希连同脚本版本号写进一个 meta 文件。这样任何时候都能确认「这份结果是不是最新脚本跑出来的」。import hashlib def data_fingerprint(df, cols): 对指定列计算哈希指纹 h hashlib.md5() for col in cols: h.update(df[col].astype(str).str.cat(sep|).encode()) return h.hexdigest()这个技巧看着不起眼但在多人协作或长期项目里能省掉大量「这份数据到底哪来的」的扯皮。我自己就吃过亏两份结果混在一起最后靠指纹才理清哪份对应哪版清洗逻辑。说到底CHARLS 数据清洗没有银弹它的复杂度来自问卷本身的多期演化。但只要你把「先模块后拼接、缺失值编码先统一、合并前查键唯一、清洗与展示分离」这几条守住剩下的就是耐心对着问卷核对映射表。我现在的习惯是每处理一个新模块先花十分钟把该模块的缺失值编码和值标签翻一遍再动手写代码这十分钟能省掉后面几小时的排查。希望帮到你。本文还有配套的精品资源点击获取