☰
CHARLS数据清洗实战:从原始数据到可分析面板的完整教程与源码
2026/10/9 20:12:14 网站建设 项目流程

简介:这份资源是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_categoricals=False) # 在候选列名里找到实际存在的 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_categoricals=False是关键参数。CHARLS 的 .dta 文件里很多分类变量带 Stata 值标签,如果让 pandas 自动转换,你会拿到字符串标签而不是数值编码,后续做回归或合并时非常麻烦。统一转成字符串再 strip,是为了处理某些年份 ID 带空格或前导零的情况——前导零丢失会导致跨年匹配大面积失败,这个坑很隐蔽,现象是「明明应该是同一个人,合并后却对不上」。

2.3 年份对齐:变量名映射表怎么建

跨年份清洗最耗时的不是写代码,是建变量映射表。CHARLS 同一个概念在不同年份可能叫da001、da002,也可能换了前缀。我的做法是维护一张 CSV 映射表,而不是把映射硬编码进脚本:

概念2011 变量2013 变量2015 变量统一名
性别ba0001ba0001ba0001gender
出生年ba0002ba0002ba0002birth_year
自评健康da001da002da003self_health
婚姻状态be001be001be002marital
def 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(columns=rename_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_cols=None): """把特殊编码统一替换为 NaN,ID 列除外""" 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_categoricals=True会给你「男」「女」字符串,False会给你 1、2。选哪个取决于你的下游工具:如果后续用 statsmodels 做回归,数值编码更方便;如果要出描述性统计表,字符串更直观。

我的习惯是清洗阶段保留数值编码,同时单独存一份值标签字典,需要展示时再映射回去:

def get_value_labels(path, var): """从 Stata 文件提取某变量的值标签映射""" import pandas as pd df = pd.read_stata(path, convert_categoricals=True) 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_categoricals=False) df["year"] = yr frames.append(df) panel = pd.concat(frames, ignore_index=True, sort=False) return panel

逻辑说明:sort=False避免 pandas 对列名排序,保持原始顺序便于核对。ignore_index=True重建索引,防止不同年份索引重复导致后续 merge 出错。拼接后立刻检查pid + year是否唯一,这是面板数据的基本要求。

4.2 横向合并:模块间 merge 的三种连接方式

模块之间靠pid + year合并。用哪种 join 取决于你的研究问题:inner只保留所有模块都有的样本,left保留主模块全部样本。我一般以人口学模块为主表做 left join,这样不会因为某个模块缺失而丢掉个体。

def merge_modules(base, others, on=["pid", "year"], how="left"): """以 base 为主表,逐个合并其他模块""" result = base for name, df in others.items(): before = len(result) result = result.merge(df, on=on, how=how, 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_col="pid", time_col="year"): """输出面板平衡性报告""" 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(subset=keys, keep=False) 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+", "", regex=True)彻底清理。

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", encoding="utf-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, index=False) 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 数据清洗没有银弹,它的复杂度来自问卷本身的多期演化。但只要你把「先模块后拼接、缺失值编码先统一、合并前查键唯一、清洗与展示分离」这几条守住,剩下的就是耐心对着问卷核对映射表。我现在的习惯是每处理一个新模块,先花十分钟把该模块的缺失值编码和值标签翻一遍,再动手写代码,这十分钟能省掉后面几小时的排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询