R 临床实战From SAS to R, In Depth
全书目录 / 实战案例
C5

ClinicalTrials.gov 试验景观:API v2 抓取与 JSON 扁平化

竞品情报、立项调研、可行性分析——SAS 时代你要等数据管理员把注册库导出成 SAS 数据集;在 R 里,httr2 + jsonlite 让你直接从 ClinicalTrials.gov 的公开 API 拉一手数据。本案例抓一页糖尿病试验,做出状态分布、相位交叉表与赞助方 Top10。

C5.1 背景与学习目标

ClinicalTrials.gov 由美国国家医学图书馆(NLM)运营,是全球最大的试验注册库。2024 年起官方主推 API v2:REST 风格、返回嵌套 JSON、免注册免 API key。对临床 Programmer,这是接触"试验之外"的真实世界数据管道的第一课——你要处理网络请求、超时、嵌套结构与分页,这些在 SAS 里都有对应物(filename url、PROC HTTP、PROC JSON),但在 R 里代码量少一个数量级。

学完本案例,你应能:

  1. 用 httr2 的管道式 API(request → req_url_query → req_perform → resp_body_json)直连 API v2,并用 fields 参数裁剪返回字段;
  2. 把深层嵌套的 JSON 列表扁平化为 data.frame——对应 SAS 的 PROC JSON map + DATA step 拆解;
  3. 用 dplyr 的 count() 与 base 的 table() 快速产出"试验景观"频数表——对应 PROC FREQ。

C5.2 数据源

数据源:ClinicalTrials.gov(NLM 运营),API v2 公开、免注册、无需 API key,数据为公共注册信息。本案例以 query.cond=diabetes 抓取一页(pageSize=100)研究快照,自动缓存到 cases/cache/ctgov_diabetes.rds,断网可复现。提醒:v2 的 pageSize 单页上限为 1000,取全量需用响应里的 nextPageToken 翻页——本案例的一页样本只够"景观速写",不够写报告。

C5.3 连接与抓取:httr2 管道 + 缓存回退

# C5:ClinicalTrials.gov 试验景观扫描(API v2 直连;离线回退 cases/cache/)
# 运行:Rscript cases/c5_trials_landscape.R
suppressPackageStartupMessages({ library(httr2); library(jsonlite); library(dplyr) })

cachedir <- file.path(if (grepl("cases$", getwd())) ".." else ".", "cases", "cache")
dir.create(cachedir, showWarnings = FALSE, recursive = TRUE)
cf <- file.path(cachedir, "ctgov_diabetes.rds")

fields <- paste(c("NCTId", "protocolSection.conditionsModule.conditions",
                  "protocolSection.sponsorCollaboratorsModule.leadSponsor.name",
                  "protocolSection.designModule.phases",
                  "protocolSection.statusModule.overallStatus"), collapse = ",")
fetch <- function() {
  js <- request("https://clinicaltrials.gov/api/v2/studies") %>%
    req_url_query(query.cond = "diabetes", pageSize = 100, format = "json",
                  fields = fields) %>%
    req_timeout(90) %>%
    req_perform() %>%
    resp_body_json()
  js$studies
}
st <- tryCatch({ s <- fetch(); saveRDS(s, cf); s },
               error = function(e) {
                 cat("[cache] 网络失败:", conditionMessage(e), "\n读取:", cf, "\n")
                 readRDS(cf)
               })
逐行解读:
  1. fields:API v2 默认返回完整嵌套 JSON(单条研究可达几十 KB),用点路径只取 5 个字段,payload 立刻小一个数量级——这是 v2 相对 v1 的重要新能力。
  2. request() %>% req_url_query() %>% req_timeout(90) %>% req_perform() %>% resp_body_json():httr2 的管道式 HTTP——每一步返回 request 对象,最后一步解析。对照 SAS:相当于 filename u url '...'; + PROC HTTP + PROC JSON 三件套压缩成一行链。
  3. resp_body_json() 把 JSON 解析成 R 的嵌套 list(jsonlite 亦可);js$studies 取出研究数组——v2 响应顶层是 studies + nextPageToken。
  4. tryCatch + saveRDS:与 C4 相同的"在线优先、缓存回退"骨架,网络失败时打印异常信息再读本地快照。

C5.4 扁平化:嵌套 JSON → 宽表

flat <- data.frame(
  nct = sapply(st, function(x) x$protocolSection$identificationModule$nctId),
  cond = sapply(st, function(x) paste(unlist(x$protocolSection$conditionsModule$conditions), collapse = "; ")),
  sponsor = sapply(st, function(x) x$protocolSection$sponsorCollaboratorsModule$leadSponsor$name),
  phase = sapply(st, function(x) paste(unlist(x$protocolSection$designModule$phases), collapse = "/")),
  status = sapply(st, function(x) x$protocolSection$statusModule$overallStatus),
  stringsAsFactors = FALSE
)
flat$phase[flat$phase == ""] <- "NA/EARLY1"
逐行解读:
  1. sapply(st, function(x) ...):对每条研究逐一取字段并收成一个向量——对应 PROC JSON 用 map 定义字段路径后在 DATA step 里逐变量取值。
  2. conditions 与 phases 是数组:unlist + paste(collapse = "; ") 把多值压成分号串,等价于 SAS 里 CATX(';', of cond1-condN)。
  3. stringsAsFactors = FALSE:老 R 用户(和 SAS 转 R 用户)的好习惯——别让字符列悄悄变 factor;R 4.x 默认已是 FALSE,写明是为了可移植。
  4. 最后一行补空:部分器械/早期试验没有 phases,取出来是空串,统一记为 "NA/EARLY1",避免交叉表里出现无名类别。

C5.5 步骤 1–2:抓取概况与状态分布

cat("== 步骤 1:抓取概况 ==\n")
cat("研究数:", nrow(flat), "(API v2, query.cond=diabetes, pageSize=100)\n")

cat("\n== 步骤 2:状态分布 ==\n")
print(flat %>% count(status, sort = TRUE))
# 实跑捕获(R 4.5.0)
== 步骤 1:抓取概况 ==
研究数: 100 (API v2, query.cond=diabetes, pageSize=100)

== 步骤 2:状态分布 ==
                 status  n
1             COMPLETED 57
2               UNKNOWN 21
3            RECRUITING  7
4 ACTIVE_NOT_RECRUITING  5
5    NOT_YET_RECRUITING  4
6             WITHDRAWN  4
7            TERMINATED  2
逐行解读:
  1. count(status, sort = TRUE) ↔ proc freq; tables status / order=freq;:一行完成分组计数 + 按频数降序。
  2. 本页 100 条里 COMPLETED 占 57、UNKNOWN 占 21——UNKNOWN 是"状态超过两年未更新"的注册记录,做景观分析时通常单列或剔除,别当 RECRUITING 处理。

C5.6 步骤 3:相位 × 状态交叉表

cat("\n== 步骤 3:相位 × 状态 ==\n")
print(table(phase = flat$phase, status = flat$status))
# 实跑捕获(R 4.5.0)
== 步骤 3:相位 × 状态 ==
               status
phase           ACTIVE_NOT_RECRUITING COMPLETED NOT_YET_RECRUITING RECRUITING
  NA                                4        23                  3          4
  NA/EARLY1                         1        13                  1          1
  PHASE1                            0         5                  0          1
  PHASE1/PHASE2                     0         0                  0          1
  PHASE2                            0         2                  0          0
  PHASE2/PHASE3                     0         1                  0          0
  PHASE3                            0         8                  0          0
  PHASE4                            0         5                  0          0
               status
phase           TERMINATED UNKNOWN WITHDRAWN
  NA                     1       9         2
  NA/EARLY1              0       7         0
  PHASE1                 0       0         0
  PHASE1/PHASE2          0       0         0
  PHASE2                 0       1         1
  PHASE2/PHASE3          0       0         0
  PHASE3                 1       3         1
  PHASE4                 0       1         0
逐行解读:
  1. table(a, b) ↔ proc freq; tables a*b / norow nocol nopercent;:base R 的交叉表,零依赖。
  2. 行名出现 NA 与 NA/EARLY1 两类"无相位":前者是抓取时 phases 缺失(NA 字符),后者是我们统一补的标签——真实项目里应先统一口径再交叉,这个"脏"输出恰好演示了清洗顺序的重要性。
  3. PHASE3 全部处于 COMPLETED/TERMINATED/UNKNOWN/WITHDRAWN,没有 RECRUITING——只是一页样本的巧合,不要据此下结论(见下方 SAS 误区)。
SAS 误区:把 pageSize=100 的一页当成总体直接出报告。API 返回的只是按默认顺序排的一页样本,就像在 SAS 里 obs=100 读了前 100 条就做全库统计。正式分析必须用 nextPageToken 翻页取全量(单页上限 1000),或改用 R 包 ctrdata 把注册库批量入库后再查。

C5.7 步骤 4–5:赞助方与条件词 Top10

cat("\n== 步骤 4:牵头赞助方 Top10 ==\n")
print(flat %>% count(sponsor, sort = TRUE) %>% slice_head(n = 10), row.names = FALSE)

cat("\n== 步骤 5:条件词 Top10(条形图数据)==\n")
cond_long <- flat %>%
  mutate(c1 = sub(";.*$", "", cond)) %>%
  count(c1, sort = TRUE) %>% slice_head(n = 10)
print(cond_long, row.names = FALSE)
cat("== C5 完成 ==\n")
# 实跑捕获(R 4.5.0)
== 步骤 4:牵头赞助方 Top10 ==
                           sponsor n
                  Novo Nordisk A/S 4
                 Assiut University 2
             Eli Lilly and Company 2
                  Korea University 2
                            Sanofi 2
             University of Chicago 2
 University of Maryland, Baltimore 2
                       AstraZeneca 1
                             Bayer 1
        Baylor College of Medicine 1

== 步骤 5:条件词 Top10(条形图数据)==
                        c1 n
         Diabetes Mellitus 9
           Type 2 Diabetes 9
                  Diabetes 8
 Diabetes Mellitus, Type 2 6
  Type 2 Diabetes Mellitus 4
             Diabetic Foot 3
       Diabetic Foot Ulcer 3
      Diabetic Retinopathy 3
              Hypertension 3
                   Obesity 3
== C5 完成 ==
逐行解读:
  1. count(sponsor, sort = TRUE) %>% slice_head(n = 10):计数降序后取前 10——对应 SAS 里 PROC SQL group by ... order by cnt desc 再 obs=10 的两步,这里一个管道。
  2. sub(";.*$", "", cond):正则取分号前第一个条件词,等价于 scan(cond, 1, ';');.*$ 意为"从分号到结尾都替换为空"。
  3. Top10 里 Novo Nordisk、Eli Lilly、Sanofi、AstraZeneca 与多所大学并存——糖尿病领域"大厂 + 学术中心"混合生态的一页速写。
  4. 条件词榜揭示注册库的术语不统一:"Diabetes Mellitus / Type 2 Diabetes / Diabetes Mellitus, Type 2"是同一概念的不同写法。真实景观分析要先做词表归一(或改用 MeSH 字段),否则 Top 榜被同义词稀释。

C5.8 解读要点

  • 免 key 直连:GET https://clinicaltrials.gov/api/v2/studies?query.cond=... 即可用;httr2 的管道把 URL、参数、超时、解析分层管理,比 SAS 的 filename url 样板代码短得多,也更容易加重试与日志。
  • fields 裁剪:用点路径(如 protocolSection.designModule.phases)只要需要的字段,这是 v2 的推荐用法,能显著提速并简化解析。
  • 扁平化套路:sapply(list, function(x) x$a$b$c) + unlist/paste(collapse) 处理标量与数组两类节点;空数组会塌成空串,要显式补类别。
  • count/table 即 PROC FREQ:count(x, sort = TRUE) 是 order=freq 的 tables 语句;table(a, b) 是二维交叉表。出报告级频数表再上 gtsummary。
  • 缓存即复现:网络案例必须把抓取结果落盘(RDS),tryCatch 自动回退——递交语境下,还应记录抓取时间戳与查询参数,作为数据溯源的一部分。

测验:在 ClinicalTrials.gov API v2 中,试验相位(如 PHASE3)应从哪个字段路径获取?

选 B。v2 把研究信息组织在 protocolSection 下:设计相关(相位、随机化、掩蔽)在 designModule,招募状态在 statusModule(overallStatus 是 RECRUITING/COMPLETED 等,不含相位)。注意 phases 是数组——早期/器械试验可能为空,需要像本案例一样补类别。另一个常考点:pageSize 单页上限是 1000,超过要用 nextPageToken 翻页。

C5.9 练习

  1. (易)把 query.cond 换成 hypertension、obesity 或你关注的疾病词重跑,对比状态分布与赞助方 Top10 有何不同(记得换缓存文件名,避免读到旧快照)。
  2. (中)先取赞助方 Top10,再子集化做"赞助方 × 相位"交叉表(table(flat2$sponsor, flat2$phase)),对应 SAS 的两维 PROC FREQ;观察大厂与学术中心的相位构成差异。
  3. (难)在 fields 里追加 protocolSection.statusModule.startDateStruct.date,解析出开始年份,用 count(year) + ggplot2 画该疾病领域的年度启动趋势图;若样本不足,先用 nextPageToken 翻页抓多页再画。

C5.10 扩展阅读

  • ClinicalTrials.gov API v2 官方文档 — 端点、查询语法、字段结构与分页(nextPageToken)规范 参考
  • ctrdata(CRAN) — 把 CTG/EUCTR 注册数据批量抓取到文档型数据库再用 dplyr 查询,适合大规模景观分析与定期监测 进阶
  • httr2 官方文档 — 请求管道、重试(req_retry)、限流(req_throttle)与 OAuth,联网数据管道的完整工具箱 进阶

下一案例:C6 teal 探索器——把 C1 推导好的 ADaM 装进 pharmaverse 的 teal 框架,做一个带过滤面板的交互式临床数据浏览器。