做CHFS数据处理,最让人头痛的往往不是模型怎么跑,而是数据怎么从一坨原始问卷变成能直接回归的面板。前阵子我把2015到2019年三轮CHFS的清洗过程完整录成了视频,配套的do文件也一并放了出来,很多朋友留言说“原来你也是这样处理的”“早点看到这个能省三天时间”。这里就把视频里没法展开的细节、以及录制之后又补充的一些操作经验,写成一篇完整的长文。无论你是刚开始接触CHFS的小白,还是已经被脏数据折磨到想放弃的资深用户,这篇文章都值得收藏。
所谓CHFS,全称是中国家庭金融调查,由西南财经大学中国家庭金融调查与研究中心组织,目前公开的年份包含2011、2013、2015、2017、2019等轮次。我做的是2015到2019年的三轮追踪数据,因为这三年的问卷结构相对稳定,而且正好覆盖了家庭金融领域最常用的一批指标。数据清洗听起来是个体力活,实际上它决定了你后面所有回归结果的可靠性。变量名不统一、样本权重对不上、家庭成员编号错位、收入口径混乱,任何一个环节没处理好,结果都可能完全跑偏。这篇文章我会按照自己实际清洗的顺序,把整体框架、核心变量处理、跨年合并、以及Stata和Python结合使用的思路全部过一遍。
1. 为什么CHFS数据必须系统化清洗:从原始问卷到可用面板的差距
1.1 原始数据的问题不是“脏”,而是“不在分析形态”
很多第一次拿到CHFS数据的人会有个错觉:既然官方已经发布了Stata格式的dta文件,是不是直接merge就能用了?我最初也这么以为,结果发现原始CHFS数据虽然字段齐全,但它的组织方式完全是“问卷导向”而不是“分析导向”。
举几个最典型的例子。家庭资产部分,同一套房子的房产价值、剩余房贷、房产数量是存放在不同模块里的,你要算“净房产”就得自己拼。收入部分,工资性收入、经营性收入、转移性收入分散在几十个变量里,稍微口径不一致,加总出来的总收入就可能是错的。更麻烦的是家庭成员信息,个人编号pid在追踪样本里有可能是变化的,你必须搞清楚“同一个pid是否等于同一个人”。
这些问题并不会显示成明显的缺漏或乱码,所以用肉眼去看数据,你可能觉得“挺干净啊”。但一旦你开始做描述性统计,就会发现家庭收入居然有负十几万的样本,资产分位数莫名其妙地不对,甚至同一个人在2015年和2017年的年龄差不是2岁。这些都不是数据输入错误,而是清洗策略没有对齐字段的口径和计算方式。
1.2 没有清洗流程,等于把结论建立在不牢的地基上
如果你只是随便跑个相关性分析,也许脏数据不会立刻给你难堪。可CHFS这类大型微观调查数据,最终几乎都会用于家庭金融、消费储蓄、资产配置等领域的实证研究。审稿人对数据清洗的结果极其敏感,尤其财富不平等、收入差距这类主题,样本里一旦混入明显的异常值,结论就可能被推翻。
我见过有同学直接把家庭总收入大于零的样本全部保留,结果均值被几个超高净值家庭拉得离谱。原因很简单,问卷里金融资产、非金融资产收集得很细,但内部逻辑检查文件并不强制要求你去处理那些“填报时把单位填成万元、结果和其他人不在一个量纲上”的记录。这些都需要清洗阶段处理。
所以,系统化清洗的第一价值其实不是“删掉问题样本”,而是建立一套可追溯、可重复的规则。每个变量如何生成、哪些样本被排除、为什么排除,都要有明确说明。这样到最后跑回归的时候,你才敢拍着胸脯说:这个是真实的数据效应,而不是清洗过程的副产品。
1.3 基于do文件构建清洗流水线的好处
既然要系统化,就必须有工具和流程。我选择以Stata的do文件作为主清洗流程,原因有几个:
- Stata是绝大多数经管领域研究者的主力工具,do文件随时可改可跑。
- CHFS官方发布的dta是Stata格式,变量标签、值标签都比较完整,Stata里处理最自然。
- 清洗过程涉及大量条件判断和替换,do文件的日志功能可以完整记录每一步,方便回归前自查。
视频里演示的do文件就是按“原始数据文件夹路径设置—导入历年数据—变量统一命名—生成核心指标—合并面板—异常值处理—保存干净数据”的顺序来的。这套流程我会在后面的章节里逐步拆开讲。如果你更习惯用Python,其实也可以用pandas搭配numpy做同样的事情,只是Stata在样本筛选和描述性统计的交互体验上仍然有优势。我自己的做法是主流程用Stata,遇到复杂的长宽表格转换再用pandas补一刀,两种工具并不冲突。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 2015-2019年三轮数据概况与do文件设计起点
2.1 历年数据文件的组成和核心文件定位
CHFS每年发布的数据包里通常包含多个dta文件,常见的有:家庭数据文件(hh)、个人数据文件(ind)、以及部分年份的主样本数据文件。从我录教程时用的2015年和2017年数据来看,最核心的就是家庭成员信息表、家庭收支资产表、保险保障表等模块。
我开始清洗时做的第一件事不是立刻看变量,而是建立一份“数据字典”,把每个dta文件的文件名、观测单位、主键字段记录下来。CHFS的主键设计通常是这样的:
- hhid:家庭识别码,跨年份的唯一家庭标识;
- pid(或pline):家庭成员在家庭内部的个人识别码,但要注意不同年份可能使用不同字段名。
拿2015年来说,家庭数据文件里hhid是唯一主键,个人数据文件里hhid+pid共同构成个人主键。2017年数据个人主键可能变成hhid+pline,这个差异如果不提前确认,后面合并个人数据时就会出错。我的do文件里第一步就处理这个:不管原始字段叫什么,统一rename成hhid和pid,再按这个标准合并。
2.2 建立原始数据只读、生成数据跟项目走的目录结构
很多人的数据清洗失败,原因不在代码,而在文件夹管理。CHFS原始dta文件动辄几百兆,你不可能每次都复制一份大文件出来,但又不能把原始文件改得面目全非。我在视频里推荐的目录结构长这样:
code复制CHFS_project/
├── rawdata/
│ ├── 2015/
│ ├── 2017/
│ └── 2019/
├── do/
├── newdata/
├── log/
└── result/
rawdata里存放官方原版dta,永远只读。do文件放所有清洗和计算脚本。newdata放处理后的中间与最终数据。log文件夹放Stata运行日志,万一结果出了问题,翻日志就知道是哪一步动了手脚。result放描述性统计、回归结果输出表格。
这个看起来很简单,但很多人在一个目录里堆了十几个脚本和几十个数据文件,最后自己都搞不清哪里是干净的。do文件设计的第一原则就是:脚本运行之后,只往newdata和log写入内容,rawdata绝不碰。这样就算代码写错了,重新跑一遍也不会污染原始数据。
2.3 通过append和merge两种方式规划跨年数据
CHFS跨年数据的使用方式,取决于你的研究设计是重复截面还是追踪面板。如果是重复截面,那每年单独整理一份家庭层面数据,然后append到一起,加一个year变量即可。如果是追踪面板,就需要以历年的hhid为基础,把同一个家庭在不同年份的数据merge到一起。
视频里的do文件两种方式都做了演示,但我个人更建议,除非你的研究必须使用严格的面板样本,否则优先用append方式建立“多期混合截面”。原因有两点:一是CHFS的追踪样本存在流失和换户,严格面板的样本量会缩小很多;二是append之后可以使用延长时间效应的回归框架,数据利用更充分。
不过如果你的核心解释变量是家庭资产变动,那还是老老实实merge成面板。后面我专门有一节讲跨年合并的坑,这里先记住do文件里必须给所有变量加年份前缀或后缀,不要直接覆盖。
3. 家庭层面核心变量清洗详解:收入、资产、负债与人口特征
3.1 家庭总收入:没有“一键生成”的变量
CHFS家庭数据里可能有一个总收入变量,但它覆盖的口径和很多研究需要的“可支配收入”并不完全一致。我清洗时会根据问卷明细,自己重新加总一个fin_income,规则如下:
- 工资性收入:包含受雇就业的税后工资、奖金、补贴;
- 经营性收入:农业经营净利润 + 工商业经营净利润;
- 财产性收入:利息、股息、租金等;
- 转移性收入:养老金、退休金、政府补助、社会救济等。
在具体代码里,我用类似下面这种累加方式:
stata复制gen fin_income = 0
replace fin_income = fin_income + wage_income if !missing(wage_income)
replace fin_income = fin_income + business_income if !missing(business_income)
replace fin_income = fin_income + property_income if !missing(property_income)
replace fin_income = fin_income + transfer_income if !missing(transfer_income)
注意要先确认每个分项变量是不是已经是年化数值。CHFS问卷有的部分问的是“上个月”,有的问的是“去年”,清洗时乘上相应月份数或折算成年度口径,不然加总会差很多。这也是pandas派上用场的地方:我可以把年份、是否年化口径等元信息放在一个表里,然后用脚本批量检查。
3.2 家庭总资产与净资产:从明细到总额的逻辑
家庭总资产 = 非金融资产 + 金融资产。非金融资产又包括房产、车辆、土地、耐用品、生产经营性固定资产等;金融资产包括存款、股票、基金、债券、理财产品、现金、借出款等。
我在视频里说过,千万不要只看官方文档里“总资产”那个变量,因为不同年份它的计算方式可能更新过。稳妥做法是自己从明细变量出发加总,然后与官方总资产变量做交叉验证。如果差异过大,优先检查缺失值、零值和极值。
举个例子,2015年数据里有一批家庭在“股票市值”上填的是0,但另一批家庭填的是缺失值。如果不加区分,简单加总时都会当成0处理,但实际含义完全不同:一个明确回答没有股票,一个是拒绝回答。这种差别的处理策略取决于你的研究问题,至少要在do文件里生成一个dummy变量标识缺失来源,这样后面做敏感性分析时不会被动。
净资产 = 总资产 - 总负债。负债主要包含住房贷款、汽车贷款、经营贷款、信用卡欠款、其他借款等。同样,加总前要确认贷款余额是“当前还未偿还的余额”,而不是“当初贷款总额”。这类口径问题在CHFS原始数据里都有对应标签,清洗时多看变量标签和问卷原文,不要凭感觉猜。
3.3 人口统计学变量与家庭层面的聚合
家庭层面的回归分析里,通常还需要户主或家庭主要决策者的人口特征。但“户主”这个概念在CHFS里并不一定叫hh_head,各年变量名称也有差异,有时需要借助成员关系字段来识别。
我在实际清洗中采用的方法是:先看家庭数据文件里有没有现成的“户主个人编号”,如果有,就用它去个人数据文件匹配户主的年龄、性别、教育程度、婚姻状态。如果没有,就找家庭成员关系字段中标识为“户主本人”的记录。
聚合其他人口特征时,重点包括:
- 家庭规模:直接数当前同住家庭成员人数;
- 老年抚养比、少儿抚养比:需要分年龄段人数;
- 户主年龄、性别、教育年限:作为回归里的控制变量;
- 家庭是否有自有住房、农业户口、城市户口等。
我遇到最坑的问题是,2015年家庭规模直接用count成员数,但2017年数据里“成员数”包含了不在世或者已搬离的家庭成员。后来每次聚合前都要先根据“是否同住”变量筛选。这个细节如果不用do文件固定下来,换个人跑或换个年份跑都会出错。
3.4 缺失值、零值和异常值的处理策略
清洗CHFS这类家庭金融数据,最重要的三个词是:缺失值、零值、异常值。三者绝不能混为一谈。
- 缺失值:包括拒答、不知道、不适用三种情况。我的处理是生成占位码,例如-1、-2、-3,在加总和回归前再决定是排除还是多重插补。
- 零值:明确回答为0的,一般保留;但某些变量(如收入)为0,需要判断是否合理。离退休老人无工资收入但可能有养老金,如果总收入为0,通常说明填报不完整。
- 异常值:我一般看变量的分布,用1%和99%分位数做缩尾(winsorize),而不是直接删除。比如家庭总收入,原始数据里可能有年收入几千万的样本,超过真实分布太多,缩尾比删除更稳健。
具体到Stata里,缩尾的标准写法是:
stata复制winsor2 fin_income, cuts(1 99) replace
如果你的Stata没装winsor2,也可以用center命令或者手动按分位数替换。不过我更建议在do文件里保留原始值,生成一个新变量fin_income_w,这样万一审稿人要求看原始分布,你还能还原。
4. 面板数据合并与跨年变量一致性处理
4.1 跨年hhid的继承与匹配问题
做面板数据时,首先要确保2015、2017、2019年的hhid确实指向同一个家庭。CHFS官方通常会在新一年数据中给出一个“追踪样本匹配变量”,比如hhid在2017年数据里可能对应2015年的旧hhid。问题是这个字段在不同年份的位置和命名不一定相同。
我在do文件里的处理方式比较笨但很稳妥:
stata复制use chfs2015_hh.dta, clear
keep hhid
gen year = 2015
tempfile h2015
save `h2015'
use chfs2017_hh.dta, clear
gen year = 2017
keep hhid old_hhid
// 如果有旧hhid字段,把它匹配过去;如果没有,就只能用hhid本身
merge 1:1 old_hhid using `h2015'
这里有个经验教训:不是每一年都提供了旧hid匹配,2019年可能官方数据包里已经有panel_id之类的变量,也可能没有。如果实在没有,就需要根据个人层面的身份证号(如果有)或家庭成员姓名+出生年份进行模糊匹配,但这个操作很容易出错,我一般不建议小白做。更稳妥的方法是去官网下载历年数据库代码表,确认当年的追踪匹配字段名称。
4.2 变量名和值标签的统一
CHFS每一年问卷会有微调,所以同一个概念的变量名很可能不一样。比如“年龄”在2015年可能是age,在2017年是age,但到了2019年可能变成ager。教育程度同样如此,有时是edu,有时是educ。
统一的思路不是在每个do文件里逐个改,而是先建立一个跨年变量对应表,然后循环rename和recode。用Stata做比较麻烦,我这里会先用Python的pandas把成百上千个变量名拉出来看一遍,确定哪些是核心变量,再回到Stata里做rename。清洗视频里有一段就是展示如何使用Python读取dta文件的变量名(借助pandas.read_stata):
python复制import pandas as pd
df15 = pd.read_stata("chfs2015_hh.dta", convert_categoricals=False)
df17 = pd.read_stata("chfs2017_hh.dta", convert_categoricals=False)
print(df15.columns.tolist())
print(df17.columns.tolist())
这样能很快发现差异。最终在Stata中统一变量名:
stata复制rename age_2015 age
rename ager age
4.3 权重变量与样本代表性
CHFS数据提供了家庭层面权重和个人层面权重。面板合并后,不是简单取某一年的权重,而是要看你的回归样本是哪一年。如果使用混合截面,通常把当年度权重直接作为回归权重;如果使用追踪面板,有些研究者会把两轮或三轮权重的均值或乘积作为面板权重,这个并没有统一标准,需要看期刊和审稿人的偏好。
我在do文件里会保留每个年份原有的权重变量并改名,比如wgt2015、wgt2017、wgt2019,再根据最终回归样本生成一个当前使用权重变量。这样做的好处是,你做稳健性检验时,可以随时切换权重,不需要重新merge。
4.4 merge之后的检查:家庭数、成员数、关键变量缺失率
数据合并完之后,绝不代表清洗完成。我每一次合并后都会做同样的检查清单:
- 合并后观测数是否符合预期,比如2015年家庭数应该是多少,2017年匹配上多少;
- 关键变量的缺失率是否比合并前明显上升(常见原因:merge的时候主键匹配错位);
- 同一家庭前后两轮的户主年龄差是否合理,正常应该在1到3岁之间;
- 同一家庭总资产前后两轮的数值变化是否大到不可思议,如果是,需要检查是否错误匹配到不同家庭。
我在视频里演示过一个很实用的Stata命令:
stata复制tab year, miss
misstable summarize fin_income total_asset debt
misstable能快速给出每个变量的缺失数量和百分比,每次合并后跑一遍,心里就有底了。
5. 用Stata与pandas双轨清洗:do文件是主流程,pandas做复核
5.1 为什么选择双轨而不是只用Stata
我经常被问到一个问题:既然do文件能把整个流程包下来,为什么还要学pandas?我的回答是:清洗CHFS这类数据,Stata强在统计建模和样本操作,但数据探索、变量名批量识别、复杂文本处理、长宽表转换,pandas比Stata灵活得多。有些变量需要根据问卷模块批量生成,Stata的循环写起来非常痛苦,而pandas里一个groupby.apply就搞定了。
所以我的工作习惯是:用pandas在清洗开始前做一轮数据侦察,用Stata做主流程的清洗建模,再用pandas做最终结果的复核。比如我在做分项收入加总时,先用pandas读取2015-2019三年数据,看看每个分项变量的命名和缺失分布,找到那些可能口径不一致的变量,再写成Stata的do文件处理脚本。到最后,再用pandas读一遍我生成的clean数据,检查几个关键统计量是否和Stata跑出来的一致。
5.2 pandas做跨年数据轮廓比较的实战代码
分享一段我在视频里演示过的代码,这段代码用于快速比较两年数据的核心变量覆盖情况:
python复制import pandas as pd
def profile_year(year, path):
df = pd.read_stata(path, convert_categoricals=False)
core = ['hhid', 'wgt', 'fin_income', 'total_asset', 'debt', 'age', 'edu']
exist = [c for c in core if c in df.columns]
print(f'{year}: {len(df)} obs, {len(exist)} core vars')
print(df[exist].isnull().mean().round(3) if exist else 'no core vars')
return df
df15 = profile_year(2015, 'rawdata/2015/chfs2015_hh.dta')
df17 = profile_year(2017, 'rawdata/2017/chfs2017_hh.dta')
如果在profile环节就发现某一个核心变量缺失率超过80%,那一开始就不应该把它留在主清洗流程里。比起在Stata里反复试错,先用pandas在全局看一遍,效率高很多。
5.3 双轨工具之间的数据交接格式
如果你长期做微观数据,一定会遇到Stata和pandas互相读写的场景。Stata的dta格式可以被pandas直接读取,清理后的pandas DataFrame也可以轻松导出为dta:
python复制df_clean.to_stata('newdata/chfs2015_clean.dta', write_index=False, version=118)
这样做最大的优势是变量标签可以保留。pandas里的DataFrame列名就是Stata变量名,值标签则需要手动处理。如果你的项目涉及复杂的值标签,更建议在Stata里完成recode和label define,pandas只做探索性分析和交叉验证。
我用pandas做完一轮复核后,会输出一个简短的Excel表格,记录每个核心变量的均值、标准差、缺失率、最小最大值,和Stata跑出来的结果对比。如果双方差异小于0.1%,才认为清洗过程可接受。双轨的价值就在这里,不是炫技,而是增加一层校验。
6. 我在清洗CHFS数据时踩过的坑与解决办法
6.1 2017年家庭成员编号和2015年错位
第一次merge个人层面数据时,我以为2017年和2015年一样,直接用hhid+pid就能对应。结果生成的面板数据里,同一家庭2015年户主年龄30岁,2017年变成55岁,一看就是pid匹配错位。后来发现2017年数据中个人编号字段并非pid,而是pline,且pline并不一定等于2015年的pid。
解决办法是在个人数据文件里找到官方提供的“y2015_pid”一类回映变量。如果没有,至少要用出生年月+性别+与户主关系做辅助匹配。从那之后,我再也不会想当然认为主键字段名不变。
6.2 收入变量里混入负值
某个模块中经营性收入填的是净利润,如果经营亏损就会出现负值。这在经济学意义上没错,但很多没有处理过微观数据的人直接对总收入取对数,遇到负值就会变成缺失值。
我的处理方式是:先判断负值是否可能真实存在。经营性净利润为负完全合理,但工资性收入为负就不可能合理。所以清洗时分项处理,对工资性收入、转移性收入做下限检查,小于0直接置为缺失;对经营性收入则保留负值。最终总收入可能出现负数,但这时的负数是真实亏损,而不是数据错误。
6.3 房产市值口径在不同轮次之间不一致
CHFS的房产模块在不同年份问了“您家这套房子现在市场价值多少”,但2015年的问卷填的是整套房产市场价值,2017年可能拆成了“自己产权部分”和“按揭部分”。这导致同一家庭的房产价值跨年不可比。
解决方法是仔细看问卷原文和变量标签,找到对应口径的变量再统一。如果2017年有“房产总价值”和“自有部分价值”,根据研究目的选择。我在do文件里会保留两个版本:房产总价值和自有部分价值,后续分析用哪个版本一目了然。
6.4 权重变量缺失但样本还存在
CHFS某些年份的数据中,部分家庭没有权重,可能是特殊样本。如果直接用权重变量,Stata默认会把缺失权重样本排除。我的做法是生成权重缺失标记变量,并单独跑一遍带和不带权重的回归,观察结果是否差很大。如果差很大,说明权重调整作用明显,回归结果里要明确说明样本是基于哪些有权重的家庭。
6.5 清洗日志比清洗本身更重要
最后这个坑可能不是数据问题,而是研究习惯问题。早期我清洗数据时没有保留日志,很多处理凭感觉,后期写论文时被审稿人质疑某个变量的生成方式,我只能翻do文件临时推导。后来我规定每次do文件运行后必须保存log文件,并在do文件开头写明项目名称、日期、数据版本、处理目的。这样哪怕半年后再回去看,也能明白当时为什么那样处理。
视频发布后,不少朋友私信我,说要求看2015到2017年合并的具体代码。我把整理好的do文件放在文章配套资源里,里面有详细的注释,也有日志生成模板。按照我的经验,第一次跑通完整清洗流程大概需要两三天,但跑通之后,后续做任何变量调整都只需要改一个do文件然后重新运行,效率会快很多。如果你在跑的过程中遇到我上面没提到的报错,建议先看Stata的变量标签和问卷原文,大部分问题都是口径不一致造成的。把数据当成一个需要“审问”的调查对象,而不是一张可以随便用的现成Excel表,你的清洗思路就会清晰很多。
