尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CHARLS数据清洗全指南:从原始问卷到可用数据集的Python框架

CHARLS数据清洗全指南:从原始问卷到可用数据集的Python框架 简介面向使用CHARLS数据库开展健康经济学、社会医学与人口老龄化研究的学者这套项目源码聚焦数据清洗、拼接与初步整理解决45岁及以上人群追踪调查数据处理中的常见痛点。压缩包内共8个文件以3个R脚本为核心分别承载依赖配置、数据清洗流程和演示分析示例另有markdown说明文档、样例CSV数据、HTML结果预览及辅助配置文件虽然整体仅12KB但结构清晰可直接对照学习或迁移到自己的项目中。已有349人学习说明具备较好的参考价值。通过阅读代码读者能完整看到从原始数据下载整理、变量拼接清洗到初步统计分析的具体实现减少因CHARLS数据缺乏查对系统而反复试错的时间并为后续开展cox回归、分位数回归及多模型比较等高级建模打好数据基础。 拿到CHARLS数据的第一周我差点被逼疯。问卷变量几百个代码册三本厚缺失值编码五花八门同一个问题在不同年份的变量名还不一致——这还没算上个体ID在不同模块之间对不上的情况。后来的日子里我陆续把这套清洗流程整理成了一个可复用的项目源码框架每次接新一批CHARLS数据就直接套用省下的时间不是一星半点。这篇教程就是基于这套框架写出来的核心就一句话把清洗CHARLS数据结构化、模块化、可复用让你不用每次从零开始对着代码册发呆。这篇文章适合所有要上手CHARLS数据的硕士生、博士生和青年教师。不管你是想做健康经济学、老龄化研究还是劳动供给分析数据清洗都是绕不开的第一道关。我默认你有一点Python和pandas基础但如果只是会用read_csv和groupby也完全够用我会把每一步为什么这么做讲清楚。1. 为什么CHARLS数据必须清洗——拿到原始数据的第一印象很多人第一次从官网下载CHARLS数据压缩包解压之后是懵的。文件命名不统一、格式混杂、变量编号像密码一样这里我先讲清楚CHARLS数据的底层结构以及不预处理就开跑回归会发生什么。1.1 CHARLS数据库的特点问卷多、变量杂、跨期长CHARLSChina Health and Retirement Longitudinal Study是国内少有的高质量微观追踪调查覆盖全国150个县区、450个村级单位样本量过万。正因为采样设计复杂它的数据文件天然就是“分模块”存储的——家户登记表、个人基本信息、健康状况与功能、医疗保健与保险、工作退休与养老金、收入支出与资产、社区问卷每块一个独立数据文件。这不是开发者偷懒而是问卷设计本身就决定了存储方式。家户信息和个人信息本来就不是一一对应的关系一个家户里有多个受访者一对多的情况用单表存反而会冗余。但这也意味着你做任何实质分析前至少要做一次“跨表拼接”这就属于清洗工作的一部分。另一个重点在于CHARLS是有追踪轮次的。2011年基线调查之后2013、2015、2018、2020年都做了追访。每一轮的数据文件结构大体相似但变量名时有调整并且ID编码规则在不同轮次之间也会有小幅变化。跨期做面板分析的人最痛的就是ID怎么对齐稍后我会单独讲这一块。1.2 不洗数据直接用会出现什么离谱结果先说最简单的坑缺失值编码。CHARLS原始数据里缺失不叫NaN而是用负数编码代替。常见的包括-1表示拒绝回答-2表示不知道-8表示不适用或未访问到-9表示缺失或数据本身没采集到。如果你拿到数据直接算均值或回归这些负数会被当成真实的数值参与计算。我第一次算受访者平均年龄时出来一个负数当时还以为数据库坏了后来才发现是缺失值没处理。再说不一致问题。同样是“学历”这个变量2011年的编码是1到92013年变成了1到11中间还新增了几个分类。直接用两期数据合并跑回归不同年份的同一个数字代表的含义完全不同出来的结果能解释出“教育程度使收入下降”这种结论然后你还得花三天时间去排查是不是模型设定错了——别问我怎么知道的。还有一个隐蔽的问题是单位。收入类变量在CHARLS里有些问的是“去年总收入”有些是“每月收入”有些则是“每周”。代码册里写得清清楚楚但合并前如果没统一口径你会得到相差几十倍的异常值。数据清洗不是洁癖是研究质量的底线。2. 清洗框架设计与项目目录规划这一章讲我在动手写代码之前先做的两件事明确分析目标、搭好项目目录。很多新手拿到数据就打开Jupyter Notebook开始一把梭写到一半发现变量选错了、文件结构乱成一团回头改代码的成本远比想象中高。2.1 先想清楚清洗目标再写第一行代码这是我在踩了无数坑之后总结出的最核心建议清洗策略完全取决于你的研究设计。同样是CHARLS数据做截面分析和面板分析的清洗逻辑完全不同前者只需要把当期变量处理好后者还要考虑跨期变量统一、ID连续性等问题。我习惯在动手前先用十分钟想清楚四个问题分析单位是个人、家户还是社区需要哪些模块的数据文件主表是哪张分析的时间范围是哪一期还是多期面板涉及哪些关键变量它们的原始编码和缺失值定义是什么想清楚这四件事你就能确定清洗的“边界”。只保留后续分析会用到的变量不要一股脑全部加载否则既慢又容易出错。需要说明的是这个“过滤”阶段节省的不仅是内存更重要的是让你在后续数据处理时不被无关变量干扰。2.2 项目目录设计与模块划分我不建议所有代码堆在一个Notebook里。那样你第一次跑通没问题但三个月后回来看连自己写的某段处理逻辑是什么都不知道。我目前使用的目录结构比较简洁新人可以直接抄charls_clean/ ├── config/ │ └── config.yaml ├── data/ │ ├── raw/ # 原始数据只读不写 │ ├── processed/ # 清洗后的数据 │ └── temp/ # 中间临时文件 ├── src/ │ ├── loader.py # 数据读取和基本类型转换 │ ├── cleaning.py # 缺失值处理、编码映射 │ ├── merge.py # 跨模块合并 │ └── validate.py # 结果校验 ├── notebooks/ │ └── demo.ipynb # 演示流程不是主战场 ├── requirements.txt └── README.md早期我为了省事把读数据、清洗、合并全写在一个py文件里跑是能跑但每次换个研究主题就得复制改名耦合严重。后来拆成loader、cleaning、merge、validate四个模块每个模块只做一件事配合config配置文件里集中存放变量名列表和各期编码规则改动成本立刻降了下来。这不是为了“代码美观”而是有实实在在的效率收益。比如CHARLS不同轮次的变量名有变动我只需要在config里加一个映射不需要改动cleaning.py的逻辑。再比如你换了研究主题只需要在config里调整筛选变量和样本条件其他模块自动适配。所谓项目源码的价值就是不需要每次都从头写一遍。3. 核心清洗流程实战——从原始问卷到可用数据集这一章是整篇文章的核心。我会把清洗流程拆成五个步骤来写从读取原始数据到最终输出清洗完成的数据集每一步都给代码和说明。代码中的变量名基于CHARLS常见的命名规则和常见实践实际使用时要结合你下载的数据包核对。3.1 读取与全局概览CHARLS官方数据的常规格式是Stata的dta文件pandas的read_stata可以直接读取。如果你下载的是sav格式或者csv也需要对应调整。读取前我强烈建议先看一眼文件的路径把所有要用的文件读进来再打印详细的shape和信息概要而不是急着写后续转换。import pandas as pd import numpy as np # 读取核心数据文件示例路径 hh pd.read_stata(data/raw/CHARLS_household.dta) ind pd.read_stata(data/raw/CHARLS_individual.dta) health pd.read_stata(data/raw/CHARLS_health.dta) for name, df in [(hh, hh), (ind, ind), (health, health)]: print(f{name}: {df.shape}) print(df.dtypes.value_counts())读取之后先做一次“体检”很重要。我习惯看三样东西一是shape的行列数二是列对应的数据类型三是关键变量的缺失占比情况。这里要看原始数据的缺失占比不是说现在就要填充而是要掌握基线情况。如果你发现某个变量全是object类型但实际应该是数值型比如收入、年龄变成了字符串多半是因为原始数据里有异常字符比如空格或“N/A”之类的占位符。这种需要转回数值型并处理异常值。3.2 缺失值识别与编码统一这是CHARLS清洗的核心环节。还记得我前面提过的负数编码吗所有负数编码都要先识别出来然后统一替换成pandas能识别的NaN这样才能用isnull、dropna、fillna等函数正常处理。我这里先定义一个函数用于统一把常见的CHARLS缺失编码映射为np.nan。MISSING_CODES [-1, -2, -8, -9] def clean_missing(df, colsNone): df df.copy() if cols is None: cols df.columns for col in cols: if pd.api.types.is_numeric_dtype(df[col]): df.loc[df[col].isin(MISSING_CODES), col] np.nan return df ind clean_missing(ind) health clean_missing(health)执行完这个函数建议立刻验证一下# 验证应该没有任何负数了 neg_count (ind 0).sum().sum() print(剩余负数编码数量, neg_count)这个验证步骤很多人会省掉我建议不要省。因为有些年份的数据可能还引入了额外的缺失编码比如-3、-5多验证一步就能发现。用数据字典里给出的缺失值定义去对照是最稳妥的办法千万不要只靠猜。另外一个细节是CHARLS里面一些负数其实有分类意义比如“-1拒绝回答”和“-8不适用”在有些分析里不能简单归为一类。如果你做的是健康行为或收入倾向性研究可能需要区分“不适用”和“真缺失”。这时我会额外生成一个flag变量保留原本的编码含义而不是直接丢弃信息。3.3 关键变量筛选与标签映射读完数据、处理好缺失之后就要开始做变量筛选和标签映射了。很多人不理解为什么要做标签映射觉得“数字编码也没关系吧反正我到时候回归用的是数字”。问题在于中间探索性分析和结果解释时全是数字会让你抓狂而且你还容易把编码方向搞反。比如自评健康有的数据1表示很好有的数据1表示很差不映射成文字标签你可能在描述统计阶段就出错。我习惯的做法是在config.yaml里面把关键变量定义集中管理后续直接调用避免在代码里到处散落硬编码。variables: id_personal: ID_person id_household: ID_household age: BA001 gender: BA002 edu: BD001 self_rated_health: DA001 mappings: gender: 1: 男 2: 女 self_rated_health: 1: 很好 2: 好 3: 一般 4: 不好 5: 很不好然后用pandas的replace或者map函数映射gender_map {1: 男, 2: 女} ind[gender_label] ind[gender].map(gender_map)这里有个细节值得注意建议用map而不是replace做列映射。map遇到没有匹配的值会返回NaN方便你发现原始数据里是否出现了不在预期编码范围内的值而replace遇到没匹配的键会保持原值不动不利于排查异常编码。筛选变量也不要只是简单选几列我建议同时做一个“变量清单”输出到Excel。每列包含原始变量名、新变量名、变量含义、数据类型、缺失比例、取值范围。这份清单可以作为数据字典的补充以后写论文数据可用性说明时可以直接引用。3.4 样本筛选与跨期数据合并样本筛选的逻辑要先写清楚再写代码。CHARLS样本筛选常用的条件有年龄范围例如只保留45岁及以上、是否有核心模块的完整访谈记录、是否为追踪成功样本。这时候用链式条件过滤即可。# 保留年龄在45~100岁之间、且性别不为空的样本 sample ind[ (ind[age] 45) (ind[age] 100) (ind[gender].notna()) ].copy() print(f筛选后样本量{len(sample)})跨期合并是查理尔斯数据中难度最大的部分。面板数据需要把2011、2013、2015等年份的数据按个人ID拼接起来。一个核心问题是不同轮次的ID命名和ID编码格式可能不同。比如2011年ID可能是纯数字的21011010101到2015年可能变成了带前缀的“H2011-001-01”之类的结构。表面上看是格式不同深层问题在于这根本不是同一个人在不同轮次的ID而是不同编码体系的ID。我建议先把各期ID统一成干净的个人ID列然后再做多期合并。具体做法是def normalize_id(series): return ( series.astype(str) .str.strip() .str.replace(-, , regexFalse) .str.replace(_, , regexFalse) .str.upper() .str.zfill(12) ) ind_2011[id] normalize_id(ind_2011[ID_person]) ind_2013[id] normalize_id(ind_2013[ID_person]) panel ind_2011.merge( ind_2013, onid, howinner, suffixes(_2011, _2013) ) print(panel.shape)合并后务必要检查匹配率。我正常情况下CHARLS追踪样本匹配率应该在70%以上如果远低于这个值不要急着往模型方向排查先回头检查ID的规范化逻辑。有时候是字符串里的空格没去掉有时候是编码前后年份位数不一致zfill能补零对齐但如果原来是字符加数字的混合体zfill就不够用了。所以规范化ID时最好先打印几个样本值肉眼确认一下格式。还有一个我踩过的坑同一期里面不同模块的ID字段名完全不同。个人模块里叫ID_person健康模块里叫ID_health社区模块里它又换成了ID_comm。这时候不要写死列名强烈建议用config配置统一映射否则每次换数据都要进代码里改一堆字段名。3.5 清洗结果校验与输出清洗的最后一步不是保存文件而是做质量校验。我之前在清洗完一个数据集后直接保存结果后续分析发现性别变量有1、2、3三种值3明显是异常编码但已经污染了分析结果。后来我悔得肠子都青了。所以在输出之前我会写一个validate.py做三件事列级检查每个变量是否存在超出预期的取值。比如性别只能出现1、2、NaN自评健康只能是1到5超出范围就要查。行级检查关键变量缺失率是否超过预设阈值比如个体ID缺失率必须为0年龄缺失率低于5%。重复值检查个人ID不应该有重复如有重复要定位是合并导致还是原始数据就有。def validate_dataset(df): assert df[id].notna().all(), 存在缺失ID assert df[id].duplicated().sum() 0, 存在重复ID invalid_gender df[gender].dropna().isin([1, 2]).mean() assert invalid_gender 1, 性别变量存在异常取值 print(数据校验通过) validate_dataset(panel)校验通过后再输出到processed目录建议存成parquet格式。如果没有parquet依赖就存csv但csv会对列类型进行简化下次读取时还得重新指定类型。parquet能保留pandas的dtypes在反复调试时能省不少事。panel.to_parquet(data/processed/charls_2011_2013_panel.parquet)4. 常见问题与排查技巧实录这一章把我这些年处理CHARLS时遇到的典型问题整理成速查表每一个都是真实踩过的坑不是编出来的。4.1 变量被自动识别成object类型数值计算全乱这种现象常见于混合了缺失编码和正常数值的变量。比如收入变量里混入了字符串“NA”或空白pandas读进来后整列变成了object。破解方法很直接先用pd.to_numeric转类型设定errorscoerce把无法转换的变成NaN然后再做缺失处理。df[income] pd.to_numeric(df[income], errorscoerce)但注意顺序如果先做了负数编码替换再转numeric有些负数字符串如“-1”会被转成-1然后在clean_missing里被替换成NaN。两步顺序错则结果不同我建议先做缺失值替换再做to_numeric兜底。4.2 多期合并后样本量暴跌ID对不上多数情况是ID格式不一致导致。不要迷信直接用字符串匹配先对ID做标准化去除空格、统一大小写、统一长度再看匹配率。另外还有可能是用了错误的合并键。比如有人想合并个人和家户数据却误用了家户ID去合并个人记录结果一对多直接膨胀成了几十万行。这个错误虽然明显但实际操作中我见过不止一次。合并前一定要确认你使用的键在这个表里是不是唯一的如果不是就要明确合并后行数会怎么变。4.3 读取大文件内存溢出电脑风扇呼呼响CHARLS原始数据虽然不像企业大数据那么夸张但如果你一次读取所有模块几百MB还是有的老一点的电脑容易卡。我的习惯是只读取所需列read_stata或read_csv都支持usecols参数。这样既省内存又加快读取速度。needed_cols [ID_person, ID_household, BA001, BA002] ind pd.read_stata( data/raw/CHARLS_individual.dta, columnsneeded_cols )如果连这个都嫌慢可以用dask或者polars做延时加载。但对CHARLS这种体量的数据pandas加usecols基本就够用了不必上分布式。4.4 变量说明文档、数据字典和实际数据对不上这是CHARLS独有的坑。官网下载的数据字典版本和实际数据版本可能不是一个版本。我遇到过2018年的数据文件里明明有某变量但数据字典里没收录也遇到过相反的情况——字典里有数据文件里找不到。遇到这种情况不要直接拿旧版的代码册硬套。最稳妥的办法是下载数据包的时候同步下载对应的代码册PDF并且一切以实际数据文件的变量标签为准。你可以用df.columns打印一看对比再写config而不是照着网上早年的旧教程写死变量名。4.5 社区变量和家户变量合并错位社区问卷和家户数据合并时键通常是社区ID不是家户ID。很多人看到社区问卷里也有“家户”二字就按家户ID合并结果错得离谱。先想清楚层级关系社区包含多个家户家户包含多个个人。合并时必须选择正确的键和合并方向。比如家户数据要加到个人数据里去就是个人表为主表左连接家户表社区表同理。不要反过来把右侧的表变为主表否则样本量会失真。5. 把清洗代码沉淀成可复用项目资产最后一章不聊函数了说点更“软”但同样重要的事。清洗代码写完之后如果只是一次性使用那还是别花太多时间封装了。但对于CHARLS这种长期、多轮次使用的数据把清洗流程沉淀成项目源码是真正的一劳永逸。5.1 用配置驱动清洗逻辑而不是改代码我之所以反复强调config.yaml是因为CHARLS的数据结构每期都有变化如果你把变量名和编码规则直接写在清洗脚本里每一期新数据出来你就要改一遍代码改着改着就可能把上一期的兼容性改坏。配置驱动的好处在于清洗逻辑只是“读配置、执行通用流程”真正变化的部分集中在config里。2020年数据出来时我只需要在config里新增一组变量映射和缺失值编码定义loader和cleaning几个模块完全不改直接跑通全流程。这不仅省时间更关键的是降低了改代码导致新bug的风险。5.2 版本管理和注释策略清洗代码一定要用git管理这一点再强调都不为过。原始数据是只读的、不进git代码和配置文件进git清洗后的数据你看着办通常不进git因为可以通过代码随时再生。另外一个容易忽视的点是注释风格。我在清洗代码里的注释原则是“写为什么不写是什么”。比如代码里出现一个奇怪的转换逻辑我会注释“CHARLS 2013年的教育编码比2015年少了2个类别这里做统一映射”而不是写“把两列合并”。前者以后回来看还能快速理解背景后者只是把代码翻译成中文没有增加信息量。5.3 从Notebook到脚本的演进时机很多初学者习惯全程用Notebook写清洗代码。说实话Notebook做探索性分析是非常好用的可以边跑边看结果还能画图验证。但如果你已经把清洗流程跑通了准备用在正式研究里我强烈建议把核心逻辑迁移到py脚本中通过命令行或import调用。原因很实在Notebook的“可复现性”比脚本差。哪怕你按顺序、从头到尾运行一遍不同阶段之间的中间变量依赖了你手动操作的变量状态下次换台机器或换个环境可能根本跑不通。脚本思路则是把每个环节的输入输出显式化每个函数有明确的输入和返回依赖关系清晰谁来看都能复现。我在实际操作中的体会是清洗CHARLS数据的整个过程看起来是体力活其实处处需要判断力。就拿“该清洗到什么程度”这个问题来说并没有统一标准完全取决于你的研究问题。做面板分析的人要尽量保留可追踪样本做截面分析的人更关注当期变量质量研究收入和资产的时候负值和极值的处理策略又会不一样。没有所谓“一套代码走天下”的方案但有一个结构清晰、可灵活调整的清洗框架绝对能让你的研究工作轻松一大截。最后再分享一个小技巧在清洗的每个关键节点都保存一份带版本号的中间数据比如charls_2011_raw.dta、charls_2011_clean.parquet、charls_2011_analysis.parquet。哪怕后面处理逻辑改错了也能快速回到上一个正确版本不需要从头跑一遍这也是我踩了太多次“跑了一晚上发现某步错了”的坑之后总结出来的习惯。本文还有配套的精品资源点击获取
返回列表