简介:NHANES数据库下载整理源码包面向医学研究者、公共卫生从业者及R语言数据分析师,尤其适合在科研课题、健康监测或营养流行病学场景中需要系统获取和处理美国全国健康与营养调查数据的人群,帮助解决数据获取入口分散、批量下载与整合困难的问题。压缩包共5个文件,以R语言脚本为核心,辅以网页说明、任务清单及配置文件,整体仅8KB,结构精炼易查阅。目前已有245人学习下载,适合入门级到中级的数据分析用户快速上手。资源内容覆盖网页手动下载与nhanesA包批量下载两种路径,并演示了数据读取、清洗、整合及导出为常见格式的完整流程;同时针对下载过程中的网络异常与版本更新问题给出了处理思路,可帮助使用者避开常见坑点,高效完成从原始数据到可用分析数据集的搭建。
1. NHANES 数据库下载整理:真正的耗时点不在下载,而在关联和清洗
NHANES 数据库下载整理,说白了就是对付一堆按两年周期发布的 XPT 文件。美国 CDC 把人口学、身体测量、实验室、问卷数据按调查周期拆成几十个文件发布,每个文件名都带周期后缀,比如 2013-2014 周期的人口学文件叫 DEMO_H.XPT,2015-2016 周期就变成 DEMO_I.XPT。手动去官网目录页一个文件一个文件地另存,很容易漏掉中间某几个,漏了还不自知;等后面 merge 的时候发现样本量对不上,才回头补,这来回成本比写脚本高得多。这篇笔记直接给出一套能跑的源码流程:自动生成下载清单、批量拉取 XPT、清洗列名、多周期合并成规整结构。适合做临床回顾、营养流行病、统计建模的人,不想在数据准备阶段耗掉大半精力。
2. NHANES 数据组织与下载机制:先搞清周期和后缀,再写第一行代码
我最早写下载脚本时犯过一个低级错误:对着一个博客里贴的文件名直接复制去下载,结果 2013-2014 周期的 DEMO_G.XPT 返回 404。原因很简单,那个周期的人口学文件叫 DEMO_H.XPT,不叫 DEMO_G.XPT。NHANES 的数据组织有自己一套命名规则,先把这两条规则搞清楚,脚本才不会写一步错一步。
2.1 两年一个调查周期,XPT 文件名后缀是规律递增的
NHANES 自 1999 年起连续开展,每两年算一个调查周期(cycle),每个周期内的受访者样本是重新抽的,不能当作同一批人追访。数据按内容域拆成不同文件,人口学在 DEMO,身体测量在 BMX,饮酒问卷在 ALQ,糖尿病问卷在 DIQ,慢性病史在 MCQ,吸烟在 SMQ,血压在 BPQ,24 小时膳食回顾是 DRXDYT 系列,实验室数据则是 PBCD、PH、BIOPRO 等十几个模块。每个内容域一个 XPT 文件,一个周期下来几十个文件。
文件名的最大规律在后缀。1999-2000 周期不带任何后缀(DEMO.XPT),从 2001-2002 周期开始按字母递增,2001-2002 是 B,2003-2004 是 C,一路排到 2017-2018 的 J。后缀不从 A 开始是有历史原因的:A 这个位置被更早的 NHANES III 数据集占掉了,你只要记住 B 起步就不会在 2001-2002 周期上写错文件名。
| 周期 | 后缀示例 | 完整文件名示例 |
|---|---|---|
| 1999-2000 | 无后缀 | DEMO.XPT |
| 2001-2002 | _B | DEMO_B.XPT |
| 2003-2004 | _C | DEMO_C.XPT |
| 2005-2006 | _D | DEMO_D.XPT |
| 2007-2008 | _E | DEMO_E.XPT |
| 2009-2010 | _F | DEMO_F.XPT |
| 2011-2012 | _G | DEMO_G.XPT |
| 2013-2014 | _H | DEMO_H.XPT |
| 2015-2016 | _I | DEMO_I.XPT |
| 2017-2018 | _J | DEMO_J.XPT |
注意 2020 年前后官方把 2017-2020 合并成特殊周期,后缀规律和往年不一致,下之前先去官网数据目录确认实际文件名。所以代码里维护一份“周期到后缀”的映射表,比写死任何一条路径都稳。
2.2 URL 构造规则:先用 HEAD 探测,再决定要不要写下载器
NHANES 的 XPT 直链格式非常简单,规律是https://wwwn.cdc.gov/Nchs/Nhanes/<周期>/<文件名>.XPT。比如 2015-2016 周期的人口学文件就是https://wwwn.cdc.gov/Nchs/Nhanes/2015-2016/DEMO_I.XPT。很多教程教你去解析目录页 datapage.aspx 的 HTML 结构提取链接,但那个页面改版过几次,解析脚本很容易跟着废掉,我后来改成“规则拼 URL + 批量探测”,反而更稳定。
探测用 HEAD 请求就行,不用 GET,HEAD 只返回响应头不拉文件体,验证链接是否存在非常划算,还能顺带拿到 Content-Length 预知文件大小。
import requests def probe_xpt(base_url, session, timeout=10): try: resp = session.head(base_url, allow_redirects=True, timeout=timeout) if resp.status_code == 200: return True, resp.headers.get("Content-Length", "unknown") return False, resp.status_code except requests.RequestException as e: return False, str(e) candidates = [ ("2013-2014", "DEMO_H.XPT"), ("2013-2014", "DEMO_G.XPT"), # 故意写错,演示探测 ("2015-2016", "DEMO_I.XPT"), ] s = requests.Session() for cycle, fname in candidates: url = f"https://wwwn.cdc.gov/Nchs/Nhanes/{cycle}/{fname}" ok, info = probe_xpt(url, s) print(cycle, fname, ok, info)这段的逻辑是拿一批候选 URL 逐个发 HEAD,返回 200 说明链接有效,返回 404 说明文件名或周期拼错了。allow_redirects 必须开,官方服务器偶尔会把请求重定向到实际资源位置;timeout 设 10 秒,探测阶段别让脚本卡死。Content-Length 的值可以提前和本地已有的文件对比,文件大小一致就直接跳过下载,这是后面断点续传的第一层判断。
3. 下载脚本实操:manifest 生成、重试下载、XPT 读取与合并
规则摸清之后,剩下的就是纯工程问题。下面四节按我实际跑的流程走一遍:先生成清单,再下载,再读取,最后合并输出。
3.1 用 manifest 生成下载清单,别把文件名写死在代码里
下载器应该面向清单工作,而不是面向硬编码文件名工作。我用一个字典记录周期到后缀的映射,再维护一个内容域列表,两层循环生成所有目标文件信息,一次性导出成 manifest.json。后续下载器、校验脚本、日志缓存全都读这份清单,加周期或加模块只动两行配置。
# generate_manifest.py import json CYCLE_SUFFIX = { "1999-2000": "", "2001-2002": "_B", "2003-2004": "_C", "2005-2006": "_D", "2007-2008": "_E", "2009-2010": "_F", "2011-2012": "_G", "2013-2014": "_H", "2015-2016": "_I", "2017-2018": "_J", } # 只放你分析里真正用到的模块,不要追求全量下载 COMPONENTS = ["DEMO", "BMX", "DIQ", "ALQ", "MCQ", "SMQ", "BPQ"] manifest = [] for cycle, suffix in CYCLE_SUFFIX.items(): for comp in COMPONENTS: fname = f"{comp}{suffix}.XPT" url = f"https://wwwn.cdc.gov/Nchs/Nhanes/{cycle}/{fname}" manifest.append({ "cycle": cycle, "component": comp, "fname": fname, "url": url, "dst": f"xpt/{cycle}_{comp}.XPT", }) json.dump(manifest, open("manifest.json", "w"), indent=2) print("manifest items:", len(manifest))CYCLE_SUFFIX 里的空字符串是 1999-2000 周期的特殊处理,这一行必须存在,不然文件名会拼成DEMO.XPT后面的.XPT重叠。COMPONENTS 按分析需求自行增减,实验室模块很多,全量下载一是不必要,二是磁盘占用和网络耗时都要额外考虑。dst 字段我特意把周期写进文件名,避免不同周期的同名文件互相覆盖。
3.2 下载器:临时文件、重试退避、大小校验三件套
下载这块我吃过一次亏:下载到一半网络断开,脚本没报错,文件大小明显偏小,read_sas 读的时候直接抛 “unexpected end of file”。从那以后我的下载函数必带三件套:先写.part临时文件,全部写完再改名;失败按指数退避重试;下载后拿 Content-Length 跟本地文件大小比对。
# downloader.py import os import requests UA = "Mozilla/5.0 (research-download-script)" RETRY_TIMES = 3 CHUNK_SIZE = 8192 def _size_match(url: str, local: str) -> bool: try: length = requests.head(url, timeout=10, headers={"User-Agent": UA}).headers.get("Content-Length") return length and int(length) == os.path.getsize(local) except (requests.RequestException, ValueError, OSError): return False def download_one(item: dict): url, dst = item["url"], item["dst"] if os.path.exists(dst) and _size_match(url, dst): return True, f"skip {dst}" os.makedirs(os.path.dirname(dst), exist_ok=True) part = dst + ".part" for attempt in range(1, RETRY_TIMES + 1): try: resp = requests.get(url, stream=True, timeout=30, headers={"User-Agent": UA}) if resp.status_code != 200: return False, f"HTTP {resp.status_code}" with open(part, "wb") as f: for chunk in resp.iter_content(CHUNK_SIZE): f.write(chunk) os.replace(part, dst) if _size_match(url, dst): return True, dst return False, "size mismatch after download" except requests.RequestException as e: if attempt == RETRY_TIMES: return False, str(e) import time time.sleep(2 * attempt) return False, "retried out"开头的_size_match先判断本地文件是否完整,完整就直接跳过,这是重跑脚本不重复下载的关键。下载过程用stream=True配合iter_content,按 8KB 分块写盘,内存占用稳定,不会因为某个实验室文件几百 MB 把内存撑爆。os.replace(part, dst)是原子操作,临时文件写完整才替换成品,半截文件永远不会被当成成品。重试间隔按2 * attempt退避,第一次失败等 2 秒,第二次等 4 秒,给服务端一点喘息时间。
3.3 读取 XPT:read_sas 与列名清洗
XPT 文件本质是 SAS transport 格式,pandas 的read_sas可以直接读,不需要装 SAS 环境。但这里有个容易翻车的点:XPORT 格式里列名按 8 字节固定宽度存储,长度不足的部分用空格补齐,read_sas读出来的列名可能带着前导或尾部空格,直接按 codebook 的变量名去取列会 KeyError。
# reader.py import pandas as pd def load_xpt(path: str) -> pd.DataFrame: df = pd.read_sas(path, format="xport") df.columns = [str(c).strip() for c in df.columns] return dfformat="xport"告诉 pandas 按 XPORT 格式解析,不加也能自动识别,但显式写上更稳。列名统一strip()是必须动作,所有入口都走这个函数,不要在单个分析脚本里各自读文件、各自清洗。
读取后还要留意缺失码。NHANES 的 codebook 里 7777 代表 refused,8888 代表 not available,9999 代表 don't know,SAS 读取时会把这些映射成缺失值,但 pandas 读进来后有些字段仍保留原始数值。要不要统一替换成 NaN,取决于你的分析场景,先看对应 codebook 再决定,我一般只在明确知道该变量用这三个码位表示缺失时才替换。
missing_codes = [7777, 8888, 9999] num_cols = df.select_dtypes(include=["number"]).columns for col in num_cols: df.loc[df[col].isin(missing_codes), col] = pd.NA注意这段代码不能无脑对所有列执行。像年龄、身高等连续变量,如果出现 9999 大概率是异常记录,替换掉没问题;但部分问卷变量里这些码位有真实语义,替换前务必翻开该变量的 codebook 确认。
3.4 合并输出:长表适合回归,宽表适合单周期问卷
多周期数据合并时,最容易犯的错误是直接用 SEQN 当主键。SEQN 只是周期内编号,每个周期都从 1 开始编号,跨周期直接 merge 会把不同周期的人串成笛卡尔积。正确做法是给每个 DataFrame 加一列周期标签,用(cycle, SEQN)复合键做关联。
# merge_demo.py demo_files = { "2015-2016": "xpt/2015-2016_DEMO.XPT", "2017-2018": "xpt/2017-2018_DEMO.XPT", } frames = [] for cycle, path in demo_files.items(): df = load_xpt(path) df.insert(0, "cycle", cycle) frames.append(df) long_demo = pd.concat(frames, ignore_index=True, sort=False) print(long_demo.shape) print(long_demo.groupby("cycle").size())insert(0, "cycle", cycle)把周期标签放在第一列,合并后每条记录的唯一身份就是(cycle, SEQN)。concat加sort=False防止 pandas 把列名按字母顺序重排导致列顺序异常。跨周期的问卷如果列结构差异大,长表比宽表更稳妥;宽表适合单个周期内多模块横向连接,比如把 DEMO 和 DIQ 按 SEQN 拼成一张宽表做单周期分析。
4. 避坑:下载整理 NHANES 的五个典型事故现场
4.1 请求与文件完整性:403 和“下了一半还报成功”
现象:脚本第一次跑得很顺,第二次开始一批文件返回 403 Forbidden,手动浏览器访问同一个链接却正常。
原因:服务器层面的防护把没有 User-Agent 的请求当成脚本抓取,requests 的默认 UA 是python-requests,识别度极高,容易被限流或拒访。
解决:用 Session 统一设置 UA,并把 Referer 指到官网首页。我通常这样处理:s = requests.Session(),s.headers.update({"User-Agent": "Mozilla/5.0 (research-download-script)"}),下载函数内部统一走这个 Session。实测加 Referer 后限流明显减少。
现象:下载到一半网络断开,脚本没报错,但 XPT 文件大小明显偏小,read_sas 读取时直接抛 “unexpected end of file”。
原因:没用临时文件机制,GET 响应体半路断开但连接正常结束,Content-Length 对不上也没被检查,半截文件被当成品保存。
解决:先写.part临时文件,全部写完再os.replace改名;下载后拿 HEAD 的 Content-Length 和本地字节数对比,不一致就删了重下。第 3 章代码里的_size_match就是干这个的,别省这一步。
4.2 读取与合并:列名空格、后缀张冠李戴、跨周期主键
现象:两个周期的 DEMO 文件直接按 SEQN merge,结果人群记录数膨胀了几十万。
原因:SEQN 是周期内编号,每个周期都有编号 1、2、3……的人,直接用 SEQN 当主键会把不同周期的人当成同一个人,产生大量错误组合。
解决:用(cycle, SEQN)复合键关联。合并时写上left_on=["cycle", "SEQN"], right_on=["cycle", "SEQN"],或者更简单,把两列拼成一列key = cycle + "_" + SEQN再 merge。
现象:对照某个教程下载 2011-2012 周期数据,拿到一堆 404。
原因:那不是当前周期的文件后缀。2011-2012 是 G,2013-2014 是 H,一字之差全错;更隐蔽的是某些特殊文件命名会有例外,不能默认所有模块后缀都整齐划一。
解决:manifest 生成后先打印一份周期和文件对照表核一遍,或者跑一遍 HEAD 探测,404 的立刻露出来。我现在习惯把对照表直接导出 CSV,下载完成后再拿 CSV 和本地文件逐一比对。
现象:读回来的 DataFrame,用 codebook 里的变量名取值直接 KeyError,比如df["LBXBPB"]报不存在。
原因:XPORT 格式列名固定 8 字节,长度不足用空格填充,read_sas 在某些版本里保留这些空格,列名实际是"LBXBPB "带尾空格,肉眼看不出来。
解决:读取后统一执行df.columns = [str(c).strip() for c in df.columns],把这件事做成读取函数的一部分,所有入口共用,别分散到每个脚本里各自处理。
5. 工程化改造:并发下载、日志缓存与配置驱动
单文件串行下载能跑通,但几十个文件慢慢爬很煎熬。这节把前面的代码改成可以长期维护的小工具:并发控制、断点缓存、配置驱动三个维度各讲一段。
5.1 并发下载:max_workers=4 是稳的起点
并发不是越大越好,CDC 的静态服务器对突发并发很敏感,开 16 个线程很容易触发限流。我实测 4 个并发配合少量间隔是最稳的组合,既不慢也不会把服务器惹毛。每线程独立 Session,避免连接池在多线程下的共享状态问题。
# download_concurrent.py import threading from concurrent.futures import ThreadPoolExecutor, as_completed local = threading.local() def _session(): if not hasattr(local, "s"): local.s = requests.Session() local.s.headers.update({"User-Agent": UA, "Referer": "https://wwwn.cdc.gov/"}) return local.s def download_with_session(item): session = _session() url, dst = item["url"], item["dst"] # 这里把 download_one 里的 requests.get 换成 session.get # 其余重试、校验逻辑完全一样 return download_one(item)threading.local()保证每个线程一个 Session,hasattr判断当前线程是否已初始化。max_workers=4的线程池不需要额外加锁,任务完成后通过as_completed逐个取结果,失败的任务打印出来单独重跑。
with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(download_with_session, item): item for item in manifest} for fut in as_completed(futures): item = futures[fut] ok, msg = fut.result() if not ok: print("[FAIL]", item["url"], msg)5.2 日志与断点缓存:重跑只补缺失文件
下载的断点续传不仅靠.part文件,还要一份本地缓存记录哪些文件已经完整落地。每次跑完脚本把成功清单写进 JSON,下次启动先读缓存,命中就直接跳过。这样中途断网、改配置重跑,都只补缺失部分。
# cache.py import json import os DONE_FILE = "done_cache.json" if os.path.exists(DONE_FILE): cache = set(json.load(open(DONE_FILE))) else: cache = set() def download_cached(item): if item["dst"] in cache: return True, f"cached {item['dst']}" ok, msg = download_one(item) if ok: cache.add(item["dst"]) json.dump(sorted(cache), open(DONE_FILE, "w"), indent=2) return ok, msg缓存文件里存的是本地绝对路径或相对路径,启动时载入成 set,查询 O(1)。写入时sorted保证输出稳定,方便 diff 查看新增了哪些文件。注意这个缓存是给脚本重跑用的,不是给 read_sas 用的,下载完成后的校验仍然以实际文件为准。
5.3 配置驱动:把周期和模块放进 JSON,代码不动
把周期、模块、输出目录、并发数全部抽到配置文件里,改需求只动 JSON,不碰 Python 代码。这对要周期性更新数据的场景尤其有用,新周期一发布,改一个配置项就能跑。
{ "cycles": ["2015-2016", "2017-2018"], "components": ["DEMO", "BMX", "DIQ", "ALQ", "MCQ"], "out_dir": "./xpt", "max_workers": 4, "ua": "Mozilla/5.0 (research-download-script)" }# build_manifest_from_config.py import json import os cfg = json.load(open("config.json")) CYCLE_SUFFIX = { "1999-2000": "", "2001-2002": "_B", "2003-2004": "_C", "2005-2006": "_D", "2007-2008": "_E", "2009-2010": "_F", "2011-2012": "_G", "2013-2014": "_H", "2015-2016": "_I", "2017-2018": "_J", } manifest = [] for cycle in cfg["cycles"]: suffix = CYCLE_SUFFIX[cycle] for comp in cfg["components"]: fname = f"{comp}{suffix}.XPT" dst = os.path.join(cfg["out_dir"], f"{cycle}_{comp}.XPT") manifest.append({ "cycle": cycle, "component": comp, "fname": fname, "url": f"https://wwwn.cdc.gov/Nchs/Nhanes/{cycle}/{fname}", "dst": dst, })配置驱动的好处是团队协作时其他人不用读代码也知道下了哪些周期、哪些模块。out_dir用os.path.join拼接,Windows 和 Linux 路径分隔符差异自动处理。新周期发布后只需在CYCLE_SUFFIX加一行映射,配置里加周期,其他代码零改动。
6. 验证下载数据的正确性:三个快速定位问题的土办法
数据下完不是终点,跑统计之前我会花十分钟做三件小事,基本能把脏数据挡在门口。
第一件是行数核对。每个周期的 DEMO 文件行数应该和官网数据目录页展示的受访者数量对得上。拿 2017-2018 周期举例,读完后df.shape[0]和官网页面上的 sample person count 做比对,误差应该在个位数以内。如果差出几千行,说明合并环节串了周期或者下载漏了文件。
df = load_xpt("xpt/2017-2018_DEMO.XPT") print(df.shape) for col in df.select_dtypes(include=["number"]).columns: hit = df[col].isin([7777, 8888, 9999]).sum() if hit: print(col, hit)第二件是检查年龄、性别这类基础变量的分布合理性。RIDAGEYR的范围应该落在 0 到 80(NHANES 把 80 岁及以上统一记作 80),RIAGENDR只应该出现 1 和 2 两个值。如果年龄列冒出来 9999,要么是缺失码没处理,要么是合并键写错把其他变量串进来了。
第三件是抽样做描述统计,和官方文档公布的参考值粗略对比。比如算一下某个已知指标的非加权均值和标准差,看看量级是否正常,不需要精确到小数,但差出十倍以上就一定是读取或合并出了问题。
从那以后,我每次下载完 NHANES 数据的第一件事就是强制走一遍这个 triple check:文件大小、DataFrame 行数、基础变量分布,三样全过再进分析环节。这套流程救过我至少两次漏文件的尴尬,现在看来当时把时间花在验证上,比多跑几个回归实在得多。希望帮到你。
本文还有配套的精品资源,点击获取