☰
NHANES数据清洗实战:从XPT到可分析宽表的完整指南
2026/10/8 9:37:52 网站建设 项目流程

简介:这份资源是面向公共卫生、流行病学与数据分析学习者的NHANES数据清洗实战代码包,针对美国国家健康与营养调查数据库体量大、变量杂、缺失多等痛点,提供从原始数据到可分析数据集的完整处理思路。压缩包共19个文件,约149.99MB,以csv数据文件为主,辅以R脚本、Shell脚本、HTML说明页、PNG图表与Markdown文档,覆盖数据生成、合并、清洗、插补与协变量筛选等环节。已有450人学习下载。读者可参考其中的R代码,借助tidyverse与haven完成XPT格式数据的读取与合并,利用mice包对缺失值进行多重插补,并依据示例完成协变量筛选与列名优化,最终得到结构清晰、可直接用于统计建模的干净数据集,适合需要快速上手NHANES清洗流程的研究者与数据分析初学者。

1. NHANES 数据清洗:为什么你拿到的原始表一导入就翻车

NHANES 数据清洗这件事,真正卡住人的从来不是模型,而是把官方 XPT 文件读进来之后那一堆莫名其妙的缺失值、特殊编码和权重列。国家健康与营养调查(NHANES)每两年一轮,公开的 SAS XPT 文件里,缺失不是 NaN,而是.、空字符串、甚至7777、9999这类占位码;血压、体测、问卷分散在不同文件,靠 SEQN 才能拼起来。很多人第一次用 pandas 读进来,describe()一看全是 object,直接懵。这篇笔记面向已经拿到 NHANES 原始文件、准备做队列分析或建模的从业者,把清洗链路拆成可复现的步骤:读文件、认编码、拼表、处理权重、导出干净宽表。代码全部基于 pandas,能直接抄。目标只有一个——让你手里的 NHANES 从“能读”变成“能用”。

2. 从 XPT 到 DataFrame:读文件与识别特殊编码

2.1 为什么 pandas 读 XPT 会给你一堆 object

NHANES 官方提供的是 SAS transport 格式(.XPT),不是 CSV。pandas 从 0.21 起内置read_sas,能直接读 XPT,但读出来的列类型经常是 object,原因是 XPT 里数值列一旦混入缺失标记,整列就被当成字符。更麻烦的是,NHANES 的缺失码不统一:连续变量常用.表示缺失,分类变量用7/9/77/99/777/999表示“拒答”“不知道”“未检测”。如果你不先处理这些码,直接mean(),7777 会把均值拉到天上。

常见做法是:读进来先不转类型,保留原始值,用一份“缺失码字典”逐列替换,再统一转 float。下面是我一般会用的最小读取脚本。

import pandas as pd import numpy as np # 读单个 XPT,format='xport' 是 pandas 对 XPT 的识别方式 df = pd.read_sas("DEMO_J.XPT", format="xport") # 先看列类型和缺失情况,别急着转 print(df.dtypes) print(df.isna().sum()) # NHANES 常见缺失码:7/9/77/99/777/999/7777/9999 以及 '.' missing_codes = [7, 9, 77, 99, 777, 999, 7777, 9999] # 只对数值列做替换,避免把分类列的真实编码误伤 for col in df.select_dtypes(include=[np.number]).columns: df[col] = df[col].replace(missing_codes, np.nan) # 统一转 float,方便后续计算 df = df.astype({c: "float64" for c in df.select_dtypes(include=[np.number]).columns})

逻辑说明:read_sas的format="xport"是关键,不写会报错。替换缺失码时只对数值列操作,因为分类列里 7 和 9 可能是真实类别(比如教育程度),不能一刀切。参数上,missing_codes列表按 NHANES 文档常见值整理,但每轮调查的码可能不同,读之前最好翻一下对应文件的 codebook。

提示:不要用df.replace(missing_codes, np.nan)全局替换,分类列会被误伤。先select_dtypes再替换,是血泪经验。

2.2 用 codebook 核对每一列的合法取值范围

光靠通用缺失码不够。NHANES 每个文件都有一份 codebook,里面写清楚每列的取值:比如RIAGENDR只有 1(男)和 2(女),RIDAGEYR是 0-80 的连续年龄,80 以上统一记 80。如果你发现某列出现了 codebook 里没有的值,要么是缺失码没清干净,要么是读文件时列错位。

我一般会写一个校验函数,把 codebook 里的合法范围写成字典,跑一遍全列检查。

# 以 DEMO_J 为例,定义关键列的合法范围 valid_ranges = { "RIAGENDR": {1, 2}, "RIDAGEYR": set(range(0, 81)), "RIDRETH1": {1, 2, 3, 4, 5}, "DMDEDUC2": {1, 2, 3, 4, 5, 7, 9}, # 7/9 是拒答/不知道,保留 } def check_valid(df, ranges): for col, allowed in ranges.items(): if col not in df.columns: continue bad = df[~df[col].isin(allowed) & df[col].notna()] if len(bad) > 0: print(f"{col} 有 {len(bad)} 行超出合法范围,示例:{bad[col].unique()[:5]}") check_valid(df, valid_ranges)

逻辑说明:isin判断是否在合法集合内,notna()排除已处理的缺失。参数valid_ranges需要你根据实际使用的文件手动整理,不同周期(如 2017-2018 和 2019-2020)的列名和取值可能微调。这一步能提前发现列错位或编码误读,比后面建模时才发现问题省事得多。

2.3 把 SEQN 当成主键:读文件时就要检查唯一性

NHANES 所有文件都靠 SEQN(受访者序号)关联。读进来第一件事是确认 SEQN 唯一,且没有重复。如果重复,要么是文件读串了,要么是某些文件本身有多行(比如实验室重复检测)。我见过有人直接merge,结果行数翻倍,查了半天才发现是某个文件里同一 SEQN 出现两次。

# 检查 SEQN 唯一性 assert df["SEQN"].is_unique, "SEQN 不唯一,检查文件是否读错" # 如果确实有多行(如重复检测),先聚合再合并 # 例如取均值:df = df.groupby("SEQN", as_index=False).mean()

逻辑说明:is_unique是快速断言,失败就直接停,别往下走。如果业务上确实需要保留多行,合并前先想清楚聚合规则,否则后续权重计算会出错。参数上,groupby("SEQN").mean()只适合数值列,分类列要用众数或保留首行。

3. 拼表与权重:把分散文件合成一张分析宽表

3.1 按 SEQN 横向合并:顺序和列名冲突怎么处理

NHANES 一轮调查有几十个文件:人口学(DEMO)、体测(BMX)、血压(BPX)、问卷(Q)、实验室(LAB)。做分析通常只需要其中几个。合并时用pd.merge,on="SEQN",how="inner"还是outer取决于你的研究问题。如果只关心有完整体测和血压的人,用 inner;如果想保留所有受访者,用 outer 再处理缺失。

列名冲突是常见坑:不同文件可能有同名列(比如RIDAGEYR只在 DEMO 里,但LBXTC和LBDTCSI可能同时出现在不同 lab 文件)。合并前先看列名,冲突的加后缀。

demo = pd.read_sas("DEMO_J.XPT", format="xport") bmx = pd.read_sas("BMX_J.XPT", format="xport") bpx = pd.read_sas("BPX_J.XPT", format="xport") # 只保留需要的列,减少冲突 demo = demo[["SEQN", "RIAGENDR", "RIDAGEYR", "RIDRETH1", "WTMEC2YR"]] bmx = bmx[["SEQN", "BMXBMI", "BMXWT", "BMXHT"]] bpx = bpx[["SEQN", "BPXSY1", "BPXDI1"]] # 横向合并,inner 保留三个文件都有的受访者 df = demo.merge(bmx, on="SEQN", how="inner").merge(bpx, on="SEQN", how="inner") print(df.shape) print(df.head())

逻辑说明:先选列再合并,能避免大量无关列和重名。how="inner"会丢掉任一文件缺失的受访者,如果你的研究需要最大样本,改成outer并在后续用dropna控制。参数上,WTMEC2YR是 MEC 检查权重,后面算加权时要用。

3.2 权重列不是装饰品:WTMEC2YR 和 WTINT2YR 怎么选

NHANES 是复杂抽样设计,官方明确要求分析时使用权重,否则结果有偏。常见权重有两类:WTINT2YR(访谈权重)和WTMEC2YR(MEC 检查权重)。如果你用的变量来自问卷,用访谈权重;如果来自体测或实验室,用 MEC 权重。合并多个周期时,权重要除以周期数(比如 4 年权重 = 2 年权重 / 2)。

# 假设只用 2017-2018 一轮,直接用 WTMEC2YR df["weight"] = df["WTMEC2YR"] # 如果是 2017-2020 两轮合并,权重除以 2 # df["weight"] = df["WTMEC2YR"] / 2 # 加权均值示例:血压收缩压 weighted_mean = np.average(df["BPXSY1"].dropna(), weights=df.loc[df["BPXSY1"].notna(), "weight"]) print(f"加权平均收缩压:{weighted_mean:.2f}")

逻辑说明:np.average的weights参数要求与数据等长,所以先用notna()对齐。参数上,权重列本身不能有缺失,如果有,要么剔除要么用多重插补。常见误用是直接df["BPXSY1"].mean(),在 NHANES 里这个值几乎不可信。

注意:权重列在合并后可能因为 inner join 丢失部分行,算加权前先确认权重列没有 NaN。

3.3 分类变量重编码:把 1/2 变成可读标签

NHANES 的分类变量都是数字编码,比如性别 1/2,种族 1-5。建模时可以用数字,但做描述统计或画图时,可读标签更直观。重编码时保留原始列,新建一列,避免覆盖后无法回溯。

# 性别重编码 df["gender"] = df["RIAGENDR"].map({1: "Male", 2: "Female"}) # 种族重编码 race_map = {1: "Mexican American", 2: "Other Hispanic", 3: "Non-Hispanic White", 4: "Non-Hispanic Black", 5: "Other Race"} df["race"] = df["RIDRETH1"].map(race_map) # 检查是否有未映射的值 print(df["gender"].isna().sum(), df["race"].isna().sum())

逻辑说明:map遇到未定义值会返回 NaN,正好用来检查是否有遗漏编码。参数上,race_map按 NHANES 官方文档整理,不同周期可能微调。重编码后建议value_counts()看一眼分布,确认没有意外缺失。

4. 缺失值处理与异常值排查:别让 7777 混进均值

4.1 缺失机制判断:MCAR、MAR 还是 MNAR

NHANES 的缺失不是随机的。体测数据缺失往往和年龄、健康状况相关(MAR),实验室数据可能因为样本量不足而缺失(MNAR)。直接dropna()会引入选择偏倚。常见做法是:先统计每列缺失比例,低于 5% 的可以考虑剔除,高于 5% 的用多重插补或加权调整。

# 缺失比例统计 missing_ratio = df.isna().mean().sort_values(ascending=False) print(missing_ratio[missing_ratio > 0]) # 对缺失低于 5% 的列,直接删行 cols_low_missing = missing_ratio[missing_ratio < 0.05].index df_clean = df.dropna(subset=cols_low_missing) # 对缺失较高的列,保留并用标记列记录 df_clean["BPXSY1_missing"] = df_clean["BPXSY1"].isna().astype(int)

逻辑说明:missing_ratio帮你快速定位问题列。dropna(subset=...)只删指定列缺失的行,不影响其他列。参数上,5% 是经验阈值,实际按研究领域调整。加缺失标记列是为了后续建模时把缺失本身当成特征。

4.2 异常值:生理范围之外的数先别急着删

血压、BMI、年龄都有生理合理范围。比如收缩压低于 60 或高于 250,BMI 低于 10 或高于 80,通常是录入错误或设备问题。但别直接删,先标记,看比例,再决定。

# 定义生理合理范围 df_clean["BPXSY1_outlier"] = ((df_clean["BPXSY1"] < 60) | (df_clean["BPXSY1"] > 250)).astype(int) df_clean["BMXBMI_outlier"] = ((df_clean["BMXBMI"] < 10) | (df_clean["BMXBMI"] > 80)).astype(int) # 统计异常比例 print(df_clean[["BPXSY1_outlier", "BMXBMI_outlier"]].mean()) # 如果比例很低(<1%),可以剔除;否则保留并标记 df_clean = df_clean[df_clean["BPXSY1_outlier"] == 0]

逻辑说明:先标记再决定,避免误删真实极端值。参数上,范围参考临床指南,不同研究可调整。剔除后记得重新检查权重分布,防止样本偏倚。

4.3 用 describe 和箱线图快速定位翻车列

清洗完跑一遍describe(),看 min/max 是否合理。如果某列 max 是 9999,说明缺失码没清干净。箱线图能直观看到离群点,但 NHANES 数据量大,画图前先采样。

# 描述统计 print(df_clean.describe().T[["min", "max", "mean", "std"]]) # 采样后画箱线图(需要 matplotlib) import matplotlib.pyplot as plt sample = df_clean.sample(1000, random_state=42) sample[["BPXSY1", "BMXBMI"]].plot(kind="box", subplots=True, layout=(1, 2)) plt.show()

逻辑说明:describe().T转置后更易读。采样是为了避免画图卡顿。参数上,random_state固定保证可复现。如果 min/max 明显异常,回到第 2 章检查缺失码替换逻辑。

5. 避坑与排查:NHANES 清洗里最容易翻车的 5 个点

5.1 现象:合并后行数暴涨 → 原因:SEQN 重复或笛卡尔积 → 解决:合并前断言唯一性

合并前对每个文件跑assert df["SEQN"].is_unique。如果某个文件确实有多行(如重复测量),先聚合到 SEQN 级别再合并。别用merge后drop_duplicates,那会丢数据。

5.2 现象:加权均值和不加权差很多 → 原因:权重列选错或没对齐 → 解决:确认权重类型并检查缺失

问卷变量用WTINT2YR,体测/实验室用WTMEC2YR。算加权前用df["weight"].isna().sum()检查,有缺失就剔除对应行。多周期合并时权重记得除以周期数。

5.3 现象:分类变量出现 7/9 → 原因:缺失码误当真实类别 → 解决:按 codebook 区分

不是所有 7/9 都是缺失。比如DMDEDUC2里 7 是“拒答”,9 是“不知道”,但RIAGENDR里没有 7/9。处理前翻 codebook,别全局替换。

5.4 现象:年龄 80 以上全是 80 → 原因:NHANES 对 80+ 统一截断 → 解决:建模时考虑截断影响

RIDAGEYR在 80 以上统一记 80,这是隐私保护设计。做年龄连续分析时,80 岁以上的信息丢失,建议分组或加标记。别把它当真实年龄算均值。

5.5 现象:XPT 读进来列名带下划线或大小写不一致 → 原因:不同周期文件命名差异 → 解决:统一列名后再合并

NHANES 不同周期的列名可能微调(如LBXTCvsLBXTC)。合并前先df.columns = df.columns.str.upper(),再核对 codebook。列名不一致会导致 merge 后出现_x/_y后缀,容易搞混。

6. 进阶:把清洗流程封装成可复用的管道

清洗代码写一次容易,复用难。我一般会把整个流程拆成三个函数:read_nhanes(file)负责读文件和替换缺失码,merge_nhanes(files, how)负责按 SEQN 合并,clean_nhanes(df)负责缺失标记和异常值。这样换一轮数据只需改文件名。

def read_nhanes(path, missing_codes=None): df = pd.read_sas(path, format="xport") if missing_codes is None: missing_codes = [7, 9, 77, 99, 777, 999, 7777, 9999] for col in df.select_dtypes(include=[np.number]).columns: df[col] = df[col].replace(missing_codes, np.nan) return df def merge_nhanes(dfs, how="inner"): from functools import reduce return reduce(lambda l, r: pd.merge(l, r, on="SEQN", how=how), dfs) def clean_nhanes(df): df = df.copy() df["BPXSY1_outlier"] = ((df["BPXSY1"] < 60) | (df["BPXSY1"] > 250)).astype(int) df["BMXBMI_outlier"] = ((df["BMXBMI"] < 10) | (df["BMXBMI"] > 80)).astype(int) return df

逻辑说明:reduce把多个 DataFrame 依次合并,避免嵌套 merge 写成一坨。参数上,how默认 inner,按需改 outer。clean_nhanes里用copy()防止修改原数据。

验证清洗结果是否可靠,我习惯做两件事:一是用官方发布的加权均值对照,比如 CDC 公布的某年平均收缩压,如果你的加权结果差超过 2 mmHg,大概率权重或缺失处理有问题;二是随机抽 10 个 SEQN,去 NHANES 官网查原始值,核对清洗后是否一致。这个习惯帮我抓过好几次列错位的 bug。

最后说个我自己的教训:早期做 NHANES 时嫌麻烦没加权重,跑出来的 BMI 和血压关系被审稿人直接质疑,返工重跑花了两周。从那以后,权重列和 SEQN 唯一性检查成了我读文件后的固定动作。希望帮到你。

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

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

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

立即咨询