简介这份资源是CHARLS数据库系列教程第二部分的配套项目源码面向健康经济学、社会学、人口统计学方向的研究者与数据分析学习者重点解决中国健康与养老追踪调查数据清洗、拼接与整理流程复杂、缺乏成熟查对系统的问题。压缩包共8个文件约12KB以R脚本为主体包含数据清洗、演示分析等核心代码另附示例CSV数据、README说明文档及HTML页面便于直接运行与对照学习。教程以甘油三酯葡萄糖指数与新发糖尿病关系的研究为例完整展示数据下载、清洗与拼接步骤代码量超过100行并预告后续将涉及Cox回归、分位数回归与多模型比较等进阶方法。目前已有399人学习适合希望掌握CHARLS数据处理技能、为实证研究打基础的中级用户参考。1. CHARLS 数据清洗教程从原始 .dta 到可分析面板数据CHARLS 数据清洗教程[项目源码] 这套资源解决的是拿到 CHARLS 原始数据后无从下手的问题。CHARLS 是中国健康与养老追踪调查数据以 Stata 的 .dta 格式分发包含家庭、个人、社区多个层级变量名像da001、da002这种编码直接打开根本不知道哪列是年龄、哪列是收入。很多人第一次拿到数据用 pandas 读进来一看几百列当场就懵了。这套源码把清洗流程拆成了可复现的脚本覆盖缺失值处理、变量重编码、多期数据合并、面板结构构建这几个核心环节。适合做老龄健康、劳动经济、社会保障方向的研究者也适合需要把 CHARLS 数据接入自己分析管线的数据工程师。下面按实际拆包顺序讲每一步都落到能跑的代码和参数上。2. 环境准备与数据读取pandas 读 .dta 的版本坑2.1 依赖安装与版本锁定CHARLS 的 .dta 文件有 Stata 13 和 Stata 14 两种格式pandas 对这两种的支持不一样。Stata 13 的 .dta 用pd.read_stata直接读没问题Stata 14 的如果 pandas 版本低于 1.0会报ValueError: Version of Stata file not supported。我一般会锁 pandas 到 1.5 以上同时装pyreadstat作为备选读取器因为 pyreadstat 对中文变量标签的保留更完整。pip install pandas1.5.3 pyreadstat1.2.0 numpy1.24.0这里锁 pandas 1.5.3 是因为 2.0 之后read_stata的convert_categoricals参数行为有变化会把带标签的变量直接转成 category 类型后续做数值运算时容易翻车。pyreadstat 作为备选读出来的 DataFrame 变量标签存在column_labels属性里不会丢。2.2 读取 .dta 并检查变量标签import pandas as pd import pyreadstat # 方式一pandas 直接读convert_categoricalsFalse 保留原始编码 df, meta pyreadstat.read_dta( CHARLS_2018.dta, apply_value_formatsFalse # 不套用值标签保留数值编码 ) # 查看变量标签确认关键变量位置 for col in df.columns[:20]: print(col, -, meta.column_names_to_labels.get(col, 无标签)) # 方式二pandas 读适合快速预览 df_pd pd.read_stata(CHARLS_2018.dta, convert_categoricalsFalse) print(df_pd.shape) print(df_pd[[ID, da001, da002]].head())apply_value_formatsFalse是关键参数。CHARLS 里很多变量用值标签存储比如性别 1男 2女如果设成 Truepyreadstat 会直接返回 男/女 字符串后面做回归就得再编码回去。我一般保持 False拿到数值编码后自己控制映射关系。meta.column_names_to_labels返回的是变量名到中文标签的字典清洗前先过一遍这个字典能省掉大量猜变量含义的时间。2.3 多期数据文件的对齐检查CHARLS 有 2011、2013、2015、2018 四期每期的变量名有继承也有新增。合并前必须做变量对齐否则 concat 之后会出现大量 NaN 列。import glob files sorted(glob.glob(CHARLS_*.dta)) dfs [] for f in files: d, m pyreadstat.read_dta(f, apply_value_formatsFalse) d[wave] f.split(_)[1].replace(.dta, ) # 标记期数 dfs.append(d) # 取交集变量避免合并后列爆炸 common_cols set(dfs[0].columns) for d in dfs[1:]: common_cols set(d.columns) common_cols sorted(common_cols) panel pd.concat([d[common_cols] for d in dfs], ignore_indexTrue) print(panel.shape, panel[wave].value_counts())wave列是手动加的期数标记后面做面板回归时作为时间维度。取交集变量这一步很多人会跳过结果 concat 出来几千列内存直接爆掉。如果确实需要某些期特有变量就单独处理不要硬合并。3. 缺失值与异常值处理CHARLS 特有的编码陷阱3.1 识别 CHARLS 的缺失编码体系CHARLS 的缺失值不是单纯的 NaN而是一套编码.表示不适用-8表示不知道-9表示拒绝回答。如果直接dropna()会把 -8 和 -9 当成有效数值参与计算均值直接被拉偏。我一般先做一轮编码替换。import numpy as np # CHARLS 缺失编码映射 missing_map { -8: np.nan, # 不知道 -9: np.nan, # 拒绝回答 .: np.nan, # 不适用 } def clean_missing(df, colsNone): 将 CHARLS 特殊缺失编码替换为标准 NaN if cols is None: cols df.select_dtypes(include[np.number]).columns df[cols] df[cols].replace(missing_map) return df panel clean_missing(panel) print(panel.isnull().sum().sort_values(ascendingFalse).head(10))replace对数值列生效如果某列是 object 类型比如字符串编码的 .需要先pd.to_numeric(errorscoerce)转一遍。替换完再看缺失比例超过 30% 缺失的变量要慎重要么做多重插补要么在模型里显式处理。3.2 关键变量的异常值截断收入、年龄、BMI 这几个变量在 CHARLS 里存在极端值。比如年龄出现 200 以上收入出现负数。这些不是缺失是录入错误或单位问题。# 年龄合理范围 18-120 panel.loc[(panel[age] 18) | (panel[age] 120), age] np.nan # 收入取对数前先处理非正值 panel[income] panel[income].where(panel[income] 0, np.nan) panel[log_income] np.log(panel[income]) # BMI 用 1% 和 99% 分位数截断 q1, q99 panel[bmi].quantile([0.01, 0.99]) panel[bmi] panel[bmi].clip(q1, q99)截断而不是删除是为了保留样本量。clip把超出分位数的值拉到边界对回归系数影响可控。如果做的是描述性统计截断前记得在论文里说明处理方式。3.3 缺失机制判断与插补选择CHARLS 的缺失不是完全随机。收入缺失往往和高龄、农村样本相关属于非随机缺失。我一般先做个简单的缺失相关性检验。# 构造缺失指示变量看它和其他变量的相关 panel[income_missing] panel[income].isnull().astype(int) print(panel.groupby(income_missing)[[age, edu_year, urban]].mean())如果income_missing1组的年龄均值明显偏高说明缺失和年龄相关直接删样本会有选择偏差。这时候用多重插补MICE比均值填充合理。常见做法是用statsmodels的MICEData或者sklearn的IterativeImputer把年龄、教育、城乡作为辅助变量。4. 变量重编码与面板结构构建从宽表到长表4.1 分类变量重编码CHARLS 的教育程度、婚姻状态、自评健康都是编码值需要映射成可解释的类别。# 教育程度重编码1-6 映射为受教育年限 edu_map { 1: 0, # 文盲 2: 6, # 小学 3: 9, # 初中 4: 12, # 高中 5: 15, # 大专 6: 16, # 本科及以上 } panel[edu_year] panel[edu_level].map(edu_map) # 婚姻状态1已婚 2离异 3丧偶 4未婚 panel[married] (panel[marital] 1).astype(int) # 自评健康1-5 反向编码数值越大越健康 panel[health] 6 - panel[self_health]map之后要检查有没有未映射的值变成 NaN。panel[edu_year].isnull().sum()如果比原始缺失多说明有编码没覆盖到需要补映射表。4.2 面板数据的长宽转换CHARLS 每期是独立文件合并后是长表格式每行一个个体-期观测。但有些分析需要宽表比如做一阶差分或者固定效应模型时需要把变量按 wave 展开。# 长表转宽表每个 ID 一行各期变量加后缀 wide panel.pivot_table( indexID, columnswave, values[income, health, married], aggfuncfirst ) wide.columns [f{var}_{wave} for var, wave in wide.columns] wide wide.reset_index() print(wide.columns.tolist())aggfuncfirst是因为同一个 ID 在同一期理论上只有一条记录如果有重复说明数据有质量问题需要回去查 ID 是否唯一。转宽表后列名变成income_2011、income_2013这种方便做跨期比较。4.3 面板平衡性检查不是所有样本四期都在。做面板回归前要确认平衡面板还是非平衡面板。# 统计每个 ID 出现的期数 wave_count panel.groupby(ID)[wave].nunique() print(wave_count.value_counts()) # 只保留四期都有的平衡面板 balanced_ids wave_count[wave_count 4].index panel_balanced panel[panel[ID].isin(balanced_ids)] print(f平衡面板样本量: {panel_balanced[ID].nunique()})如果平衡面板样本量掉太多比如从 15000 掉到 6000就要考虑非平衡面板的估计方法或者用逆概率加权处理流失样本。这一步的选择直接影响后续模型设定不能拍脑袋。5. 避坑与排查CHARLS 清洗里最容易翻车的五个点5.1 变量标签丢失导致重编码错误现象用 pandas 读 .dta 后convert_categoricalsTrue把性别转成了 男/女后续df[gender] 1全部为 False样本量变成 0。原因pandas 默认convert_categoricalsTrue会自动套用 Stata 的值标签把数值转成字符串。解决统一用convert_categoricalsFalse或 pyreadstat 的apply_value_formatsFalse拿到原始数值编码后自己控制映射。如果已经读了用pd.read_stata(..., convert_categoricalsFalse)重读一遍不要试图在字符串上做数值比较。5.2 多期合并时 ID 不统一现象concat 四期数据后groupby(ID)发现同一个人的记录分散在多个 ID 下面板结构断裂。原因CHARLS 各期的 ID 编码规则有调整2011 和 2013 的 ID 可能不是同一套。直接按 ID 合并会把人拆成多个个体。解决用官方提供的 ID 对照表做映射或者用姓名、出生年月、性别组合生成唯一标识。我一般会先做duplicated(subset[name, birth_year, gender])检查确认没有重复个体后再合并。5.3 缺失编码 -8/-9 被当成有效值现象算收入均值时结果明显偏低检查发现大量 -8 参与了计算。原因CHARLS 用 -8 表示不知道、-9 表示拒绝这些在数值列里是合法整数dropna()不会删。解决读取后第一步就做replace({-8: np.nan, -9: np.nan})并且把这个操作写进清洗管线的固定步骤。我习惯在函数入口处就做不等到分析阶段再补。5.4 宽表转长表时变量类型不一致现象pd.wide_to_long或melt之后原本是数值的列变成了 object做回归时报类型错误。原因各期同一变量的 dtype 不一致比如 2011 是 int642015 因为含缺失变成了 float64concat 后 pandas 推断为 object。解决合并前对每期数据做astype统一数值列统一转 float64。或者在 concat 之后用pd.to_numeric(errorscoerce)强制转换把无法转换的变成 NaN 再排查。5.5 面板平衡性检查遗漏导致模型设定错误现象固定效应模型跑出来系数和预期相反或者标准误异常大。原因用了非平衡面板但按平衡面板的设定跑个体效应和时间效应的交互没处理干净。解决先跑wave_count看每个 ID 的期数分布如果 1 期样本占比超过 20%说明流失严重。这时候要么限制样本到平衡面板要么在模型里加流失指示变量做敏感性分析。不要直接忽略。6. 进阶技巧把清洗管线封装成可复用模块清洗脚本写多了会发现每次换一期数据就要改路径、改变量名。我后来把整个流程封装成了一个类核心思路是把「读取-缺失处理-重编码-面板构建」四步做成链式调用每步的配置用字典传入。class CHARLSCleaner: def __init__(self, missing_mapNone, recode_mapNone): self.missing_map missing_map or {-8: np.nan, -9: np.nan} self.recode_map recode_map or {} self.panel None def load(self, files): dfs [] for f in files: d, m pyreadstat.read_dta(f, apply_value_formatsFalse) d[wave] f.split(_)[1].replace(.dta, ) dfs.append(d) common set(dfs[0].columns) for d in dfs[1:]: common set(d.columns) self.panel pd.concat([d[sorted(common)] for d in dfs], ignore_indexTrue) return self def clean(self): num_cols self.panel.select_dtypes(include[np.number]).columns self.panel[num_cols] self.panel[num_cols].replace(self.missing_map) return self def recode(self): for col, mapping in self.recode_map.items(): if col in self.panel.columns: self.panel[col _new] self.panel[col].map(mapping) return self def build_panel(self, id_colID, wave_colwave): self.panel self.panel.sort_values([id_col, wave_col]) self.panel[wave_count] self.panel.groupby(id_col)[wave_col].transform(nunique) return self.panel # 使用示例 cleaner CHARLSCleaner( recode_map{edu_level: {1: 0, 2: 6, 3: 9, 4: 12, 5: 15, 6: 16}} ) panel (cleaner .load([CHARLS_2011.dta, CHARLS_2013.dta, CHARLS_2015.dta, CHARLS_2018.dta]) .clean() .recode() .build_panel()) print(panel[[ID, wave, edu_level_new, wave_count]].head())这个类的关键设计是每步返回self支持链式调用。missing_map和recode_map作为构造参数传入换项目时只改配置不改代码。build_panel里加了wave_count列方便后续筛选平衡面板。实际用的时候我会在clean之后加一步assert检查关键变量的缺失比例是否在预期范围内比如assert panel[income].isnull().mean() 0.3超过就抛异常避免脏数据流到下游。验证清洗结果是否可靠我一般做三件事一是随机抽 10 个 ID手工对照原始 .dta 和清洗后的值确认重编码没串行二是跑一遍描述性统计看均值、标准差是否在合理区间比如年龄均值应该在 55-65 之间三是把清洗后的面板存成 parquet下次直接读省掉重复清洗的时间。从那以后我每次拿到新一期 CHARLS 数据都强制走一遍这个管线先跑通再动分析。希望帮到你。本文还有配套的精品资源点击获取