ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NHANES数据结构与Python整理:从下载到合并的完整指南

NHANES数据结构与Python整理:从下载到合并的完整指南

简介:面向医学研究与公共卫生分析场景的NHANES数据库下载整理源码包,提供官方网页手动下载与nhanesA包批量获取两条路径,并给出R语言环境下的数据读取、清洗、合并与导出实现。该方案面向流行病学、营养学等领域的研究人员,也适合需要持续跟踪健康调查数据集的数据分析师。压缩包共5个文件,以R脚本为核心,辅以HTML说明页、MD操作笔记、环境配置与忽略规则文件,整体仅8KB,结构清晰、便于快速审阅和定位核心脚本。该资源已有252人学习,无论入门还是进阶均可从中获取实用经验。借助nhanes_analysis.R脚本及配套文档,可直观理解批量下载策略、网络中断与版本更新应对思路,以及跨周期数据集整合导出的完整操作方法,减少重复踩坑,显著提升数据准备与科研分析效率。

1. NHANES 是什么:为什么公开数据库也要花大力气下载整理

摸过 NHANES 的人普遍有个共识:真正费时间的不是统计模型,而是下载和整理。NHANES 是全球范围内开放获取的连续横断面健康调查数据库,每两年发布一轮数据,覆盖人口学、饮食、体格检查、实验室和问卷五大类。对做营养流行病学、临床预测模型和公共卫生决策研究的人来说,它是难得好用的公开数据源。要用起来,得先跨三道坎:文件多、格式杂、周期之间命名不一致。有人花一个周末下载,最后发现文件没下全,或者合并时 SEQN 对不上。这篇笔记按一条能复现的路径讲清楚:怎么用脚本把指定周期文件批量拉下来,怎么把 XPT 合并成长表,以及权重和缺失值这些坑到底在哪。

2. NHANES 数据结构:轮次、文件组织与三种常用读取路线

2.1 两年一周期:连续调查的文件体系与命名规则

连续 NHANES 从 1999 年起按“两年一个周期”发布,文件夹在发布页里以 1999-2000、2001-2002 的形式命名。每个周期内部不是一个大整表,而是几十个模块文件,分别对应人口统计、体检、实验室、饮食回忆和问卷。这样划分的好处是各模块可以独立更新和补充,坏处是下载脚本必须按文件名逐个拼 URL。

模块文件命名遵循“主题前缀 + 周期后缀”的套路:2001-2002 周期在主题后加 B,之后按字母顺序推进(C、D、E……),1999-2000 这一期比较特殊,文件名不带后缀。到 2017-2020 这轮又出了带 P_ 前缀的预打包版本,命名规则又变了。所以代码里不能把“永远叫 DEMO_E”当铁律,每个周期都要刷新一次文件名清单。

下面这张表列出近二十年常见周期的文件后缀,方便对照:

周期文件后缀示例
1999-2000DEMO.XPT(无后缀)
2001-2002DEMO_B.XPT
2003-2004DEMO_C.XPT
2005-2006DEMO_D.XPT
2007-2008DEMO_E.XPT
2009-2010DEMO_F.XPT
2011-2012DEMO_G.XPT
2013-2014DEMO_H.XPT
2015-2016DEMO_I.XPT
2017-2020P_DEMO.XPT

模块方面,常用的几类如下:DEMO(人口统计)、BMX(身体测量)、BPX(血压)、DIQ(糖尿病问卷)、SMQ(吸烟问卷)、MCQ(医疗条件)、DR1TOT / DR2TOT(24 小时饮食回忆)、GHB(糖化血红蛋白)、TCHOL(总胆固醇)、HDL(高密度脂蛋白)等。挑选文件时不要一把抓,最好先按研究问题在文档页勾出目标模块,再决定下载范围。否则只下载几个文件,分析到一半发现缺关键协变量,再回去补,整个流程就断了。

2.2 XPT、CSV 还是 R 包?读取路线怎么选

早期周期只发布 XPT 格式,这是 SAS 的传输格式,本质上是一种带变量标签的二进制表。2017-2018 之后的周期官方同时提供 CSV,Excel 和 pandas 都能随时打开,但 CSV 的列类型和缺失值标记在不同周期之间并不完全统一。连续多周期分析时,我很少混用两种格式,而是统一把 XPT 作为原始数据层,全部读取后再输出为 parquet。

读取 XPT 不一定要装 SAS:R 里用 haven 包,Python 里用 pandas 自带的 read_sas 即可。如果你主要用 R,还可以去看专门封装 NHANES 下载的辅助包,它能自动按周期、按模块命好文件名并加载。Python 的世界里这类封装很少,最稳的做法是自己写一个下载脚本,读一个目录清单,按统一函数加载。

从工具链上做比较,三条路线的特点如下:

路线优点缺点适用场景
直接下 CSV打开快、预览方便只有新周期才有,老周期没有只做最近 1-2 轮、快速摸数据
Python + requests / pandas可脚本化、周期统一文件名拼错会 404,需经常维护清单做多周期合并、建模
R + haven / 辅助包读取一行命令,下载封装好网络代理设置麻烦,遇到大文件较慢R 生态为主的流调分析

我一般会选“Python 下载 + pandas 读 XPT”的组合,原因很现实:团队协作时 Python 脚本容易交给新人,出错也能从日志定位到具体文件名;R 辅助包虽然方便,但缓存和路径规则像黑匣子,换一台机器可能就找不到数据。

2.3 文档文件与变量前缀:把“黑匣子”打开

每个模块文件在官方页面都附一份文档。下载数据前,把这份文档从头到尾看一遍并不夸张:里面写清每个变量的有效范围、缺失值编码和分析建议,关于抽样权重的提示也藏在文档里。很多老手翻车不是因为代码写错,而是把问卷里的数字当成直接可用的值,实际上先要做编码映射。

变量命名也有规律可循。前缀能帮你在上百个变量里快速定位目标字段:

  • DEMO:人口统计、年龄、性别、种族
  • BMX:身高、体重、腰围、BMI
  • LBX / LBD:实验室检测结果和标志变量
  • DIQ:糖尿病相关问卷
  • DR1 / DR2:24 小时饮食回忆
  • MCQ:慢性病状况问卷
  • SMQ:吸烟与烟草使用

这些规律只能作为索引,不能替代文档。以糖化血红蛋白文件为例,核心测量变量是 LBXGH,但文档还会给出该变量的检测限信息;有些周期里同时提供用于判断“低于检测限”的 LBD 前缀标志。清洗时如果只认 LBXGH,就可能忽略检测限带来的偏倚。把这个习惯养成,后面所有坑都可以提前避开。

3. 下载数据:用 Python 脚本批量拉取指定周期文件

3.1 先列文件清单:从发布页到本地目录

下载脚本不应该是“看到什么下什么”,而是把目标周期和文件名写进一个清单文件,这样重跑脚本、换机器、交给同事都能复现。清单的来源是每个周期模块页里罗列的子文件名,常见的做法是:

# 在终端里用 curl 拉取整个目录页面的原始 HTML,再抓文件名 curl -s "https://wwwn.cdc.gov/Nchs/Nhanes/2007-2008/" | grep -oE '[A-Z0-9_]+\.XPT' | sort -u

先说明一下:这一步只是把发布目录页中出现的 .XPT 文件名全部抓出来,方便你把目标模块挑出来。如果你能直接访问网页,也可以人工把文件名粘贴进一个files.txt里。抓到结果后过滤出要用的模块,例如:

grep -E '^(DEMO|BMX|DIQ|GHB|DR1TOT)' files.txt

这段命令的作用是只保留以 DEMO、BMX、DIQ、GHB、DR1TOT 开头的文件名。这样人工核对一遍,确认档期后缀没问题,再进入批量下载。很多人忽略这一步,直接按脑子里记的文件名下载,结果旧周期后缀对不上,白白跑几十个 404。

3.2 最小可用的 Python 批量下载脚本

在本地建nhanes_raw/{cycle}目录,每轮下载前先检查同名文件是否已存在,避免重复下载。核心逻辑只有三件事:拼 URL、发请求、写文件。

import requests import time from pathlib import Path # 官方发布目录的固定前缀 base_url = "https://wwwn.cdc.gov/Nchs/Nhanes" cycle = "2007-2008" # 按周期核对后的目标文件名,后缀来自发布目录页 files = ["DEMO_E", "BMX_E", "DIQ_E", "GHB_E", "DR1TOT_E"] save_dir = Path("nhanes_raw") / cycle save_dir.mkdir(parents=True, exist_ok=True) for name in files: url = f"{base_url}/{cycle}/{name}.XPT" target = save_dir / f"{name}.XPT" # 文件已存在且大小大于 0,跳过 if target.exists() and target.stat().st_size > 0: print(f"[skip] {name}") continue try: r = requests.get(url, timeout=60) r.raise_for_status() target.write_bytes(r.content) print(f"[ok] {name}, size={target.stat().st_size}") except Exception as exc: print(f"[fail] {name}: {exc}") # 每两个请求之间停顿一秒,降低被限速的概率 time.sleep(1)

逻辑说明:base_url与cycle、文件名拼接后就是 XPT 的直链。requests.get的timeout=60只约束单次请求的等待时间,防止网络卡死时脚本挂住不动。raise_for_status()会在 4xx、5xx 时抛异常,避免把错误的 HTML 页面当成数据写进本地文件。target.exists() and target.stat().st_size > 0是断点重跑时的“后悔药”,网络中断后不需要清空目录,重新执行脚本会自动跳过已经下好的文件。

参数调整上的经验:如果你在局域网或代理环境下,timeout可以放宽到 120;如果目标周期包含大型饮食回忆文件,可以把time.sleep(1)改成time.sleep(3),避免并发请求过多触发服务器的临时拦截。脚本不需要做成多线程,NHANES 每个周期常玩就十几个文件,顺序下载五分钟内能完成,线程池带来的收益不抵维护成本。

3.3 断点续传与下载后校验:别让文件坏在起点

最小脚本能应对大多数情况,但遇到超时中断、代理不稳定、下载到一半进程被杀,仍可能留下大小不足的半个文件。这时候靠“存在就跳过”不可靠,需要校验。XPT 文件的头部会写入 SAS 传输格式的标识,读取文件的前三个字节能快速发现残缺文件:

import requests import time from pathlib import Path def download_with_resume(url, target, retries=5): target = Path(target) offset = target.stat().st_size if target.exists() else 0 # 从已有字节数开始请求,服务器支持的话会返回 206 headers = {"Range": f"bytes={offset}-"} if offset else {} for attempt in range(retries): try: with requests.get(url, headers=headers, stream=True, timeout=60) as r: if r.status_code == 200: mode = "wb" # 服务器不支持断点,重新写 elif r.status_code == 206: mode = "ab" # 从断点续传 else: r.raise_for_status() with open(target, mode) as fh: for chunk in r.iter_content(chunk_size=512 * 1024): fh.write(chunk) return True except Exception as exc: print(f"[retry] {attempt + 1}/{retries}: {exc}") time.sleep(5) return False

调用时,把脚本里target.write_bytes(r.content)替换成download_with_resume(url, target)即可。逻辑说明:Range头告诉服务器从指定字节开始返回,收到 206 就用追加模式写文件,收到 200 说明目标不支持分段续传,直接覆盖重写。iter_content(chunk_size=512*1024)是流式写入,内存只保留 512KB 的缓冲块,大文件也不会撑爆内存。

下载后的校验同样简单。XPT 文件头部通常以 SAS 标识开头,读前三个字节能过滤掉残缺文件:

def check_xpt(path): with open(path, "rb") as fh: head = fh.read(3) return head == b"SAS"

这个校验不是绝对标准,但社区一致看下来,正常的 NHANES XPT 文件头不会漏掉这个标识。批量下载后跑一轮完整性检查,再把异常文件列入重下任务,就能把“下载环节的坑”挡在分析之前。常见的失败是文件大小为零、文件头乱码、下载内容实际是报错页面,这三种情况在check_xpt下都会现形。

4. 数据整理:把 XPT 读成长表,按 SEQN 完成合并

4.1 用 pandas 读取 XPT:列名统一小写,字节列解码

拿到原始文件后,第一步是把 XPT 读入 DataFrame。pandas 的read_sas支持format="xport"参数,能直接识别 NHANES 的传输文件。读取后有两个常见问题需要立即处理:列名大小写混用、字符列以字节串形式出现。

import pandas as pd def read_xpt(path): df = pd.read_sas(path, format="xport") # 统一列名为小写,避免周期之间大小写不一致 df.columns = [str(c).lower() for c in df.columns] # XPT 中的字符列在 pandas 里通常是 bytes,转成字符串 for col in df.columns: if df[col].dtype == object: df[col] = df[col].map( lambda x: x.decode("utf-8", "ignore") if isinstance(x, bytes) else x ) return df

参数说明:format="xport"告诉 pandas 按 SAS 传输格式解析;如果你的文件实际是sas7bdat,这个参数要换成"sas7bdat"。df.columns统一小写是为了后续多个周期合并时少一个变量名大小写不一致的翻车点。字节转字符串用isinstance(x, bytes)做判断,避免对整列强制转码时报错。这段函数是后续所有合并操作的底座,建议放到单独的工具模块里复用。

读取后先看一眼数据范围:df.shape、df["seqn"].nunique()、df.head()。SEQN 是每个受访者的唯一编号,也是所有模块合并的主键。主键重复会直接造成笛卡尔积,后面合并前必须检查。

4.2 按 SEQN 横向合并:把人口、体检、问卷拼成宽表

NHANES 的数据本质上是以受访者为中心的宽表,不同模块负责同一批人的不同侧面,因此最常见操作是横向 merge。合并顺序建议先 DEMO,再按需添加其他模块,这样能保证第一张表的 SEQN 是抽样总体的全集。

def merge_nhanes(demo_path, bmx_path, diq_path): demo = read_xpt(demo_path) bmx = read_xpt(bmx_path) diq = read_xpt(diq_path) # 主键重复会引发笛卡尔积,直接抛错 if demo["seqn"].duplicated().any(): raise ValueError("seqn duplicated in demo") m1 = demo.merge(bmx, on="seqn", how="left", validate="one_to_one") m2 = m1.merge(diq, on="seqn", how="left", validate="one_to_one") return m2

逻辑说明:how="left"以 DEMO 表为基准,保留所有受访者;BMX 或 DIQ 只有完成对应检查的人才有数据,没做的行会是 NaN,这是正常现象,不要误删。validate="one_to_one"让 pandas 在合并前检查主键是否唯一,如果两组数据里有重复 SEQN,这里会直接报错,而不是悄悄生成几十万行的笛卡尔积。这个保护字段非常有用,尤其是把某周期所有文件合并时,一张表里的重复 SEQN 往往意味着下载到了重复文件名。

4.3 多周期纵向合并:统一变量名,保留分析标签

如果把多个周期拼成一张大表,操作从横向 merge 变成纵向 concat。难点不在代码,而在于周期之间的变量名和编码差异。常见的组合方式是把每个周期单独处理成宽表,抽取公共列,再pd.concat叠起来。

from pathlib import Path def load_cycle(cycle_dir, suffix, file_codes): """加载一个周期内多个模块并横向合并。""" frames = [] for code in file_codes: path = Path(cycle_dir) / f"{code}_{suffix}.XPT" if not path.exists(): # 兼容不带后缀的 1999-2000 周期 path = Path(cycle_dir) / f"{code}.XPT" frames.append(read_xpt(path)) merged = frames[0] for df in frames[1:]: merged = merged.merge(df, on="seqn", how="left", validate="one_to_one") return merged # 2007-2008 与 2009-2010 分别加载 df07 = load_cycle("nhanes_raw/2007-2008", "E", ["DEMO", "BMX", "DIQ"]) df09 = load_cycle("nhanes_raw/2009-2010", "F", ["DEMO", "BMX", "DIQ"])

纵向合并前,先把两个周期都存在的列筛出来:

common_cols = [c for c in df07.columns if c in df09.columns] # 编码口径会变的变量,先不放进来,后面单独映射 drop_cols = {"ridageyr", "riagendr", "ridreth3"} common_cols = [c for c in common_cols if c not in drop_cols] long_df = pd.concat([df07[common_cols], df09[common_cols]], ignore_index=True)

这段代码的逻辑要点:concat按列名对齐,没对齐的列会变成 NaN,因此先筛出common_cols再拼,能避免后边所有下游分析都面对一大片空列。年龄、性别、种族这类变量在周期之间编码可能调整,所以先剔除,再用文档里的编码重新映射成统一的文本标签。多周期合并时最容易翻车的点就在这里:变量名一样,值编码不是一回事,直接 concat 后分组统计就会张冠李戴。

5. NHANES 整理常见问题与避坑排查:5 个高频翻车点

5.1 权重用错:均值算出来和官方发布对不上

现象:把整理好的数据丢进统计软件算加权均值,结果和官方发布表格里的数字差了一大截,甚至方向都不对。原因:NHANES 是复杂抽样设计,不是简单随机样本。每个周期的文件里有多个权重变量,使用哪个取决于分析用到的子样本。常见组合里,全人群检查数据用wtmec2yr,访谈数据用wtint2yr,饮食数据要单独用一天饮食权重。用错权重,得到的估计就偏离目标总体。解决:打开模块文档页的分析说明,找到“推荐权重”对应段落;合并后单独确认权重变量没有在 merge 时被覆盖。多周期合并时,如果样本协议要求合并四年数据,则权重通常需要按比例调整。最简单的验证办法是先算一个官方已公布的基础指标(比如年龄标准化后的糖尿病患病率),对不上就回去查权重。

提示:不要把美国本土复杂抽样的方法套用在简单随机采样上。处理复杂抽样需要用专门的调查分析工具,普通均值函数出来的标准误也会偏小。

5.2 变量编码跨周期变:合并后分组全乱

现象:两个周期的riagendr看起来都是 1 和 2,但ridreth3的取值分布差异巨大,直接拼表后分组比例异常。原因:部分分类变量在一轮调查里改过编码方案,例如种族分类从旧版 5 组变成新版 6 组,或者问卷里“不知道”“拒绝回答”的值码发生变动。解决:纵向合并前不要只看变量名,要把每个周期的value_counts()拉出来对比。经验做法是把分类变量先映射成文本标签,再合并:

for col in ["ridreth3", "riagendr"]: print(df07[col].value_counts(dropna=False)) print(df09[col].value_counts(dropna=False))

看到两周期取值不一致,先用文档里的 codebook 把数字映射成统一的标签,再 concat。把这一步做在前面,后面分组统计、建模的特征工程就不用返工。

5.3 缺失值不是 NaN:7、9、777、999 满天飞

现象:统计某变量时出现大量 77、99、666 这类极端值,可视化时出现诡异的长尾。原因:NHANES 里“不知道”“拒绝回答”“不适用”都有专门编码。XPT 读进来后这些就是普通数字,不会被自动识别成缺失。解决:按每份文档的编码表,把无效值替换成 NaN:

import numpy as np # 示例:只处理文档里明确标为缺失的编码 missing_codes = {7, 9, 77, 99, 777, 999} for col in df.columns: if pd.api.types.is_numeric_dtype(df[col]): df[col] = df[col].replace(missing_codes, np.nan)

注意这里的坑:不是所有 7 和 9 都该清除,变量含义不同时同一数字可能是有效类别。操作前必须看文档,逐个变量确认哪些编码是缺失。把清洗函数定义成白名单映射,而不是全表无差别替换,能避免有意义的 7(比如某些问卷里的“曾经吸过烟”)被删掉。

5.4 子样本筛选错:分析人群和目标人群对不上

现象:做骨密度相关分析时,明明全库有几万人,最后匹配出来的有效样本只有不到一半。原因:部分检查项目只面向特定年龄区间或特定受检人群,例如骨密度常常只测量 40 岁以上且完成体检的受访者。盲目用全人群作为分母,统计结果不能代表目标人群。解决:每个模块文档开头会注明“Target Sample”和入选条件。在代码里先按条件过滤子样本,再挂权重。粗筛示范:

# 假设文档表明该模块仅针对 >= 40 岁 sub = df[df["ridageyr"] >= 40].copy()

这个坑常发生在多周期合并后,因为基于长表分析时很容易忘记样本本身是有条件的。每加一个模块,就回去翻一遍该模块的目标人群段,比事后写注释更可靠。

5.5 小样本亚组硬要出结论

现象:某个亚组百分比是 0% 或标准误大得离谱,置信区间横跨半个坐标轴。原因:亚组未加权计数太小,复杂抽样下的有效样本量更低。NHANES 的社区惯例是,未加权计数低于 30 的亚组不建议独立发表估计值。解决:报告前先算加权计数,观察置信区间宽度。如果亚组确实太小,考虑把相邻两个周期合并起来做分析,或把分组口径放宽。这不是代码问题,但因为它出现在整理完成之后,常常最耗时间,提前在分组设计时想好退路会更省事。

6. 进阶:把整理好的 NHANES 接进修模型与流调分析流程

6.1 权重在回归和模型训练里怎么用

整理好的长表在实际分析中,权重是绕不开的话题。做描述性统计时用对应的wtmec2yr算加权均值和百分比;做回归时如果依赖复杂抽样设定,需要把年龄、性别等协变量放进模型,再用调查加权方法估计基准点和置信区间。对做机器学习建模而言,权重更多是让训练样本的代表性更接近目标人群,像梯度提升类模型可直接传入样本权重,但要先对权重做归一化,否则数值稳定性容易出问题。无论走哪条路,都不要让“样本权重”存在于长表的某个角落而被后续转换遗忘,建议单独存成 meta 表,和特征矩阵分开管理。

6.2 从长表到特征矩阵:变量分箱与缺失插补

建模前要把长表里的连续变量整理成可用于模型的特征。年龄、BMI 这类变量在临床场景里常常被切分区间:

bins = [0, 18, 40, 60, 80] labels = ["<18", "18-39", "40-59", "60-79", "80+"] df["age_group"] = pd.cut(df["ridageyr"], bins=bins, labels=labels, right=False)

pd.cut的right=False表示左闭右开区间,避免 40 岁同时落进两个分组。缺失插补则要先回到第五章第 3 小节的清洗结果,确认缺失是真缺失而不是编码残留,再做均值或中位数插补。对高度偏态的实验室指标,插补前先做对数变换更合理。特征矩阵输出成长表后,检查每一列的缺失比例和取值离散度,再进入建模循环。

6.3 一个可复用的结果校验技巧

整理完数据后,第一件事不是跑模型,而是拿官方已发布的汇总表对一次数。选一个已知指标:比如某周期某年龄段的平均糖化血红蛋白或糖尿病患病率,按官方同样口径算一遍。偏差通常集中在两类原因:权重变量用错,或缺失值清理过度。这个步骤等价于给数据管道上了一道回归测试,之后任何一次延迟周期更新或清洗逻辑改动,都能用它快速发现异常。我自己每次做新周期合并时,会先把校验脚本固定下来,数据更新后直接跑这一格,省掉大量怀疑人生的时间。

走过的弯路里,最大的教训就是相信列名而没看文档。现在无论做哪个周期,我都会先拉一次值域分布再决定后续清洗策略。希望这些整理流程和踩坑记录,能让你少走几段冤枉路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进