ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CHARLS数据清洗全流程:pandas处理负数编码与跨wave面板合并

CHARLS数据清洗全流程:pandas处理负数编码与跨wave面板合并 简介面向CHARLS数据库使用者的一套数据清洗项目源码适用于健康经济学、社会学及人口统计学研究者可解决从数据下载到拼接整理的重复性工作。压缩包共8个文件以R脚本和示例数据为主体包含数据清洗主程序、示例分析脚本及说明文档整体仅12KB轻量而聚焦。目前已有349人学习适合需要快速掌握CHARLS数据处理流程的初学者。资源以甘油三酯葡萄糖指数与新发糖尿病关系为研究案例系统展示超过100行的清洗与拼接代码并配套演示数据集与项目说明读者可对照修改后直接用于自己的研究大幅减少原始数据整理的时间成本。 有位读者前几天私信我说他第一次用 pandas 读 CHARLS 的人口学数据文件读进来一看列名像天书数值里是一堆 -1、-2、-3他第一反应是把这些负数全部删掉。我赶紧拦住他——CHARLS 的数据清洗坑基本上都埋在这一步。这篇文章我想把一套完整的 CHARLS 数据清洗流程讲透。CHARLS中国健康与养老追踪调查是社科研究里高频使用的微观数据库样本量大、变量多、跨 wave 多看起来“下载就能用”实际上原始数据离可分析状态差得很远。这不是一篇讲理论的文章而是我多次实际处理 CHARLS 数据后沉淀下来的一套可复现方案包含从文件结构梳理、数据导入、缺失值处理、多 wave 合并到结果审计的完整代码思路。适合两类人看一类是刚接触 CHARLS、被数据文件搞到崩溃的社科研究生另一类是已经用 pandas 处理过一些数据但想建立一套规范清洗流程的从业者。1. 拿到 CHARLS 原始数据后的第一件事梳理文件结构而不是急着读数据1.1 用文件清单建立数据结构认知CHARLS 数据包解压之后目录结构通常是按 wave 组织的比如 2011、2013、2014、2015、2018 各占一个文件夹每个文件夹里再按问卷模块拆成多个子目录像人口学背景、健康状况与功能、医疗保健与保险、工作退休与养老金、收入支出与资产等。每个模块下又至少包含两个 .dta 文件分别对应家庭问卷和个人问卷。我见过太多人一上来就pd.read_stata(2018/Demographic_Background.dta)读出来一个几百列的 DataFrame 就开始逐个变量猜含义效率极低而且容易出错。正确做法是先跑一遍文件清单把“这个 wave 有哪些模块、每个模块有哪些文件、是家庭级还是个人级数据”梳理清楚。from pathlib import Path import pyreadstat import pandas as pd import numpy as np raw_dir Path(./data/raw) for wave_dir in sorted(raw_dir.iterdir()): if not wave_dir.is_dir(): continue print(f {wave_dir.name} ) for dta_file in sorted(wave_dir.rglob(*.dta)): # 只取文件名和大小快速扫一遍结构 print(f{dta_file.relative_to(wave_dir)} \t{dta_file.stat().st_size / 1024 / 1024:.1f} MB)这一步看似简单但能帮你快速判断哪些文件是核心分析对象哪些是辅助文件。比如“家庭成员登记表”和“代答人信息”这类文件很多研究根本用不到但初学者容易把它们当主数据读进来导致之后合并时 ID 对应关系全乱。另一个判断维度是文件名里的关键词Household 是家庭层面Individual 是个人层面这两个层级决定了后续所有操作的 ID 粒度建议一开始就在笔记里把文件层级关系标清楚。1.2 变量命名与代码簿唯一可靠的对照方式CHARLS 的变量命名规律性并不强。有些变量像r1gender、r2age这种一眼能看出含义但更多变量是EA001、DA001、HC001这种缩写编码光看列名完全猜不出内容。更麻烦的是不同 wave 里同一个概念可能用了不同的变量名比如 2011 年的年龄变量和 2018 年的年龄变量可能就不是同一个后缀规则。我踩过这个坑之后学乖了处理 CHARLS 数据官方代码簿Codebook和问卷必须放在手边。代码簿里会列出每个变量的含义、取值范围、缺失值编码这是数据清洗的“宪法”一切处理规则都应以它为准。读数据的时候我建议直接把变量标签variable label也读出来比对着列名一个个查效率高得多。还有个容易忽略的点CHARLS 的 .dta 文件自带变量标签和值标签但 pandas 的read_stata默认不会把这些元数据完整暴露给你。这就要用到pyreadstat库它读出的 metadata 里会附带完整的列标签和值标签映射后面做缺失值识别时非常有用。具体用法下面一节展开。2. 导入环节决定整个清洗的底子变量标签、ID 类型和字段类型一起处理2.1 用 pyreadstat 读 .dta把变量标签一起带出来导入是数据清洗的第一步也是最容易被低估的一步。pd.read_stata确实能读 .dta 文件但它在多数版本里只把列名和数值带回来变量标签不会自动挂到 DataFrame 上。如果数据文件有几百列没有标签做对照你连“这列是什么”都搞不清楚更别说处理缺失值。所以我推荐用pyreadstatdf, meta pyreadstat.read_dta( raw_dir / 2018 / Demographic_Background / Demographic_Background.dta ) # meta 里包含完整元数据 print(meta.column_labels) # 每个变量对应的中文/英文标签 print(meta.value_labels) # 值标签如 {1: Male, 2: Female, -1: 不知道}这段代码返回的meta.value_labels是一个字典键是变量名值是该变量取值到标签的映射。我之后的缺失值识别就靠它因为 CHARLS 的负数缺失编码-1、-2、-3、-4一般都有值标签通过这个字典可以自动找出所有包含缺失编码的列比手工一个个看准得多。读取之后建议马上做三件事df.shape看行数列数、df.columns看列名、df.head()看前几行数据。这几个操作几秒钟就能完成但在心理上能帮你对数据的“规模感”有个底。CHARLS 个人级文件通常有几万行几十到上百列内存上完全没有压力真正要警惕的是合并时行数膨胀这个后面说。2.2 ID 列统一成字符串再做字段类型批量修正CHARLS 数据里有不同类型的 ID比如家庭 IDHouseholdID、个人 IDIndividualID、社区 IDCommunityID以及各 wave 的追踪 ID。这些 ID 本质上是标识符不是数值但在读入时经常被 pandas 自动推断成int64或float64。一旦 ID 被当成数值处理就会出现两个问题一是有前导零的 ID 会被吞掉二是合并时左右两边的 ID 类型不一致会导致匹配失败。所以导入之后我的固定操作是把所有 ID 列统一转成字符串并去掉首尾空格id_cols [HouseholdID, IndividualID, ID] for col in id_cols: if col in df.columns: df[col] df[col].astype(str).str.strip()这一步几乎零成本但能规避后续合并时一大半的KeyError和行数异常问题。接着是字段类型批量修正。CHARLS 里性别、教育程度、婚姻状况这类分类变量应该保留数值编码1、2、3而不是转成字符串因为后续做因子化、生成虚拟变量都需要数值。而身高、体重、收入这类连续变量要确保它们是float64否则计算均值、分位数时会报错。# 数值型变量统一转 float避免后面算描述性统计时爆类型错误 num_cols [Height, Weight, BMI, Income] for col in num_cols: if col in df.columns: df[col] pd.to_numeric(df[col], errorscoerce)errorscoerce的作用是遇到不能转成数值的内容自动置为 NaN这是处理脏数据的常用技巧。实测下来这比先查一遍有没有脏字符再处理要省事得多。3. 缺失值不是删就完了CHARLS 负数编码的正确处理方式3.1 CHARLS 的负数编码到底有哪些CHARLS 数据里缺失值的表现形式和一般数据集不太一样。一般数据集缺失就是空值或NA但 CHARLS 的原始数据里大量缺失是以负数的形式存在的。常见的有这么几类编码含义处理建议-1不知道视研究问题决定是否纳入“不知道”类别-2拒绝回答通常视为缺失-3不适用 / 跳答需结合题目逻辑判断-4遗漏通常视为缺失需要注意不同 wave 的代码簿在个别编码上可能存在细微差异所以处理前一定要翻对应 wave 的官方代码簿确认。我见过有人把 -3 和 -4 一律当缺失处理结果发现某个变量里 -3 其实是“不适用”而非“没有回答”导致样本量和结论出现偏差。这里的关键认知是负数缺失编码不是无意义数据它包含了受访者的应答状态信息。比如“不知道”和“拒绝回答”在统计分析上的含义是不同的前者可能是信息不足后者可能是敏感话题回避。如果你一上来就.dropna()或把所有负数删掉这些信息就永远流失了。正确做法是先把它们显式地识别出来再根据研究设计决定保留还是转 NA。3.2 一段可复用的缺失值映射与审计代码我处理缺失值的第一步不是直接替换而是先“审计”——搞清楚哪些列包含负数各有多少。这里可以用前面提到的meta.value_labels来快速定位def identify_missing_cols(df, meta, neg_values(-1, -2, -3, -4)): missing_cols set() for col, labels in meta.value_labels.items(): if col not in df.columns: continue if any(k in labels for k in neg_values): missing_cols.add(col) # 额外扫描所有数值列防止有些负数没被标标签 for col in df.select_dtypes(include[np.number]).columns: if (df[col] 0).any(): missing_cols.add(col) return sorted(missing_cols) missing_cols identify_missing_cols(df, meta) print(f发现 {len(missing_cols)} 个包含缺失编码的变量)跑完这段你对数据的“脏点”分布会有一个清晰认识。接下来再决定替换策略MISSING_MAP {-1: np.nan, -2: np.nan, -3: np.nan, -4: np.nan} def clean_missing(df, missing_mapNone): df df.copy() if missing_map is None: missing_map MISSING_MAP df df.replace(missing_map) return df有一点要强调我通常只对真正要用到的分析变量做替换不会一上来就对全表所有负数进行统一处理。原因是有些变量比如经济行为中的“亏损”变量负数可能是有效值直接 replace 会误伤。如果你不确定就先只处理identify_missing_cols返回的列里、经过业务判断确认属于缺失编码的那些变量。3.3 值为 0 不代表缺失先看代码簿再动手这可能是 CHARLS 数据清洗里最容易引起误操作的一点。很多变量里 0 是一个合法编码代表“没有”或“否”但在新手眼里 0 很容易被误判为缺失值。举个例子慢性病数量、饮酒频率、吸烟数量这类变量0 代表这个受访者没有慢性病、不喝酒、不吸烟。如果你把 0 替换成缺失后面做出来的患病率、行为发生率全部偏低。再比如家庭资产变量里的 0可能是真实申报为零资产也可能是漏填这两者必须靠代码簿和逻辑校验来区分。所以我的原则是任何变量的特殊取值先查代码簿里的值标签确认含义后再决定处理方式。这个步骤看起来繁琐实际每个变量最多多花一分钟但能避免你拿着清洗了半个月的数据跑完回归后才发现结论不可靠的灾难。4. 跨 wave 合并把多年零散文件变成能直接跑回归的面板数据4.1 合并前的 ID 唯一性检查CHARLS 是追踪调查跨 wave 合并是刚需。但在合并之前有一件事必须做验证 ID 的唯一性。如果 ID 有重复合并后行数会异常膨胀你以为自己在做 1:1 匹配实际变成 1:n 匹配甚至 n:m 匹配后续所有分析都建立在错误的行结构上。def check_id_uniqueness(df, id_colIndividualID): dup_count df.duplicated(subset[id_col]).sum() print(f{id_col} 重复记录数: {dup_count}) if dup_count 0: print(df[df.duplicated(subset[id_col], keepFalse)].head())如果发现重复先不要急着去重而是看重复的原因。有可能是同一个个体在同一年出现了多条记录比如代答人和本人各有一条也有可能是数据本身有合并问题。对于家庭级数据ID 的粒度是 HouseholdID这时需要在HouseholdID级别上检查同时要留意同一个家庭里不同成员的个人 ID 是不同的不要在家庭级数据里用个人 ID 做唯一性检查。4.2 变量名统一与纵向拼接多 wave 数据合并最麻烦的不是 concat 本身而是变量名和编码在不同 wave 之间不完全一致。比如 2011 年的年龄变量可能叫r1age2013 年的叫r2age如果直接拼接这两列会成为两个不同的列没法纵向分析。我的做法是先建立一个变量映射字典把不同 wave 的同概念变量统一成同一个列名var_mapping { 2011: {r1age: age, r1gender: gender}, 2013: {r2age: age, r2gender: gender}, }每次读入一个 wave 的数据后按映射重命名再筛选出你需要的那部分列最后纵向拼接wave_dfs [] for wave in [2011, 2013, 2015, 2018]: file_path raw_dir / wave / Demographic_Background / Demographic_Background.dta df_wave, _ pyreadstat.read_dta(file_path) df_wave df_wave.rename(columnsvar_mapping[wave]) df_wave[wave] int(wave) df_wave clean_missing(df_wave) wave_dfs.append(df_wave[[IndividualID, wave, age, gender]]) panel pd.concat(wave_dfs, ignore_indexTrue) print(panel.shape)这里有一个实操细节拼接之前先把不需要的变量丢在门外。有些初学者喜欢把每个 wave 的全部列都 concat 进来结果生成了一个几千列的 DataFrame不光占内存变量名冲突问题也被放大了无数倍。先想清楚研究需要哪些变量再按需提取合并会让整个流程干净很多。4.3 合并后的样本量核对合并完成之后建议立刻做一次样本量核对。CHARLS 各 wave 的官方文档或者官网发布的样本量数据是最权威的参照系。你合并后的个体数如果和官方口径对不上说明要么 ID 处理出了问题要么变量筛选时误删了样本。核对逻辑很简单按 wave 分组统计唯一个体数。sample_check panel.groupby(wave)[IndividualID].nunique() print(sample_check)举个例子如果官方说 2018 年有效追踪样本是多少人而你统计出来的人数明显偏少那就要回头检查缺失值替换是否把某些 ID 也替换成了 NaN。这类 bug 我用df[df[ID].isna()]排查时抓到过不止一次。另外如果后面要把多个模块的数据合并到个人主数据上建议每次 merge 之后都检查一下行数变化。预期是 1:1 或 n:1 的关系行数就不该明显变动。一旦发现行数暴涨第一反应就应该是检查是否有重复 ID而不是继续往下做。5. 清洗结果审计与源码交付把“能跑”变成“可信”5.1 重复值、取值边界、逻辑关系三重校验数据清洗的终点不是“跑通”而是“可信”。清洗完的数据要能经得起复现和审查所以我给自己定了一个强制要求清洗完成后必须做一遍三重校验。第一重是重复值校验。用duplicated()检查主键是否有重复这一层在合并阶段做过但清洗末尾还要再做一次防止中间步骤引入了新问题。第二重是取值边界校验。对关键变量设置合理的取值范围超出范围的样本要单独输出出来检查range_rules { age: (18, 110), height: (120, 210), # cm weight: (30, 200), # kg } def check_ranges(df, rules): for col, (lo, hi) in rules.items(): if col not in df.columns: continue bad_idx df[(df[col] lo) | (df[col] hi)].index if len(bad_idx): print(f{col}: {len(bad_idx)} 条取值超出 [{lo}, {hi}]) print(df.loc[bad_idx, [col]].head())这一步的价值在于很多录入错误比如身高被录成 1800cm靠肉眼根本看不出来只有数值边界检查能抓到。第三重是逻辑关系校验。这一点依赖你的研究设计比如“未患慢性病”的人不应该有“用药数量”的回答“已退休”的人不应该在工作状态变量里选择“在职”。这类逻辑规则需要手动列出然后逐条验证。我第一次做逻辑校验时发现了几十个自相矛盾的样本追根溯源后发现是变量筛选时把两个相似问题弄混了这种错如果不在清洗阶段暴露到了论文阶段就是硬伤。5.2 描述性统计与图形辅助检查数值校验之外我习惯再做一轮描述性统计和图形检查。df.describe()可以快速呈现各数值型变量的均值、标准差、分位数一眼就能看出有没有极端异常值。箱线图则更适合辅助判断连续变量的离散情况尤其是身高体重这类容易出现录入误差的指标。import matplotlib.pyplot as plt fig, axes plt.subplots(1, 2, figsize(10, 4)) df.boxplot(columnheight, axaxes[0]) df.boxplot(columnweight, axaxes[1]) plt.tight_layout() plt.savefig(./output/cleaning_checks.png, dpi150)需要说明的是箱线图里的“异常值”不一定要删除。老年人身高体重的生理分布本来就比年轻人宽一个 90 岁的受访者身高 155cm 并不会因为“低于 Q1-1.5*IQR”就该被剔除。图形检查的意义在于提醒你去关注这些样本而不是代替你做删留决定。我在实际项目里的做法是先标记所有可疑样本再逐一回到原始问卷或代码簿确认而不是批量删除。5.3 工程化与源码交付建议如果你只是自己跑一次分析清洗脚本写得随意点问题不大。但如果这份代码以后要复用或者要拿给同门、导师、合作者看我建议花一点时间做工程化整理。目录结构上我常用这样的分层方案project/ ├── data/ │ ├── raw/ # 原始数据只读不改 │ ├── interim/ # 中间结果 │ └── processed/ # 清洗后的最终数据 ├── scripts/ # 按步骤编号的脚本 ├── logs/ # 运行日志 └── output/ # 图表和结果文件配置文件单独抽出来把 wave 列表、文件路径、缺失值映射表、取值范围规则都放在一个config.py或 YAML 文件里。这样换一个 wave、加一批新数据时只需要改配置不用重新翻代码。日志也值得写不需要很复杂logging模块记录一下每个关键步骤处理了多少行、替换了多少缺失值将来排查问题会省很多时间。如果清洗过程中涉及随机抽样记得固定随机种子保证结果可复现。交付源码时还有几个细节路径一定不要写死成自己电脑上的绝对路径用相对路径或从配置读取README 里写清楚环境依赖和运行顺序如果有多个步骤建议每个步骤一个脚本并在文件名里用数字前缀固定顺序比如01_load_data.py、02_clean_missing.py、03_merge_waves.py。这些习惯看似琐碎但在实际协作中能省下大量沟通成本。回到最初那个私信那位读者后来按这套流程重新走了一遍跑完以后跟我说“原来那个 -1 和 -2 不是一样的东西我之前全删了等于把信息丢了。”CHARLS 数据清洗就是这样表面上是技术问题本质上是数据理解问题。每一条处理规则背后都应该有代码簿的依据和研究的考量。把这些规则想清楚、写明白、落成代码你的数据不但能用于当前这篇论文以后做任何跟 CHARLS 相关的课题都能直接复用这套底子。本文还有配套的精品资源点击获取
返回列表