全书目录 / 实战案例
C1
SDTM → ADaM → Table 1 全链路
把第 2 章的 CDISC 语义和第 4 章的 rtables 串成一条完整生产链:合成 SDTM 进、可递交 Table 1 出、双编程比对收尾。这是 SAS Programmer 最熟悉的工作流,我们用 R 原样重走一遍。
背景与学习目标
SDTM→ADaM→TLF 是临床编程的主干道。本案例用 pharmaverse 生态(pharmaversesdtm 供数、admiral 推导、rtables 出表、diffdf 比对)走完一个最小但完整的生产闭环,脚本为 cases/c1_sdtm_adam_t1.R,仓库根目录下 Rscript cases/c1_sdtm_adam_t1.R 即可复现。学完你应当能够:
- 目标 1:用 admiral 从 SDTM 的 dm/ae 推导 ADSL 与 ADAE,理解 ITTFL、AOCC01FL 这类受试者级/记录级标志变量的推导逻辑与 SAS 等价写法。
- 目标 2:用 rtables 产出按 ARM 分层、SEX 分行、AGE 出 n 与 Mean (SD) 的递交级 Table 1,并体会它与 PROC TABULATE + ODS RTF 的异同。
- 目标 3:用 diffdf 完成双编程比对,把 PROC COMPARE 的 QC 纪律原封不动平移到 R。
数据源:pharmaversesdtm 合成 SDTM 数据(xanomeline 模拟研究,dm 306 行 × 28 列、ae 1191 行)· 获取方式:
install.packages("pharmaversesdtm"),数据为 CRAN 包内置,加载即用、无需联网 · 许可:随包分发,以包内 DESCRIPTION/LICENSE 声明为准(开源许可,可自由用于教学与验证演练)。步骤 1:SDTM 源数据与环境装配
# C1:SDTM → ADaM → Table 1 全链路(pharmaversesdtm + admiral + rtables + diffdf)
# 运行:Rscript cases/c1_sdtm_adam_t1.R
source(file.path(if (grepl("cases$", getwd())) ".." else ".", "cases", "common.R"))
suppressPackageStartupMessages({ library(rtables); library(diffdf) })
cat("== 步骤 1:SDTM 源数据 ==\n")
cat("dm:", nrow(pharmaversesdtm::dm), "行 /", ncol(pharmaversesdtm::dm), "列;ae:",
nrow(pharmaversesdtm::ae), "行\n")
# 实跑捕获(R 4.5.0) == 步骤 1:SDTM 源数据 == dm: 306 行 / 28 列;ae: 1191 行
逐行解读:
source("cases/common.R"):装载共享推导函数,等价于 SAS 的%include "macros.sas"或 autoexec 里挂标准宏库。grepl("cases$", getwd())让脚本在仓库根目录或 cases/ 目录下都能跑——路径自适应是 R 脚本的小工程习惯。pharmaversesdtm::dm:::直接从包里取数据集,不需要先"libname + set";类比 SAS 里sashelp.class这种即取即用。nrow() / ncol():先数行数再干活,对应你在 SAS log 里确认NOTE: There were 306 observations read的习惯。
步骤 2:admiral 推导 ADSL
推导逻辑封装在 cases/common.R 里。admiral 的 derive_* 函数族就是 R 世界的"经验证标准宏库"——每个函数带文档、带单元测试,替代你司那些口口相传的 SAS 宏:
derive_adsl <- function() {
dm <- pharmaversesdtm::dm
dm %>%
derive_vars_dt(new_vars_prefix = "BRTH", dtc = BRTHDTC) %>%
derive_vars_dt(new_vars_prefix = "TRTS", dtc = RFSTDTC) %>%
derive_vars_dt(new_vars_prefix = "TRTE", dtc = RFENDTC) %>%
derive_vars_duration(
new_var = AGE, new_var_unit = AGEU,
start_date = BRTHDT, end_date = TRTSDT,
out_unit = "years", trunc_out = TRUE
) %>%
derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(TRTSDT, TRTEDT)) %>%
mutate(
ITTFL = if_else(!is.na(TRTSDT), "Y", "N"),
SEX = as.character(SEX), ARM = as.character(ARM), RACE = as.character(RACE)
) %>%
select(USUBJID, ARM, ARMCD, ACTARM, SEX, AGE, AGEU, RACE,
TRTSDT, TRTEDT, TRTSDY, TRTEDY, DTHFL, ITTFL)
}
cat("\n== 步骤 2:admiral 推导 ADSL ==\n")
adsl <- derive_adsl()
print(adsl %>% count(ARM, ITTFL))
cat("ADSL 维度:", dim(adsl), "\n")
# 实跑捕获(R 4.5.0) == 步骤 2:admiral 推导 ADSL == # A tibble: 4 x 3 ARM ITTFL n <chr> <chr> <int> 1 Placebo Y 86 2 Screen Failure N 52 3 Xanomeline High Dose Y 84 4 Xanomeline Low Dose Y 84 ADSL 维度: 306 14
逐行解读:
derive_vars_dt():把 ISO 8601 字符日期(BRTHDTC/RFSTDTC/RFENDTC)解析成数值日期,等价于BRTHDT = input(BRTHDTC, yymmdd10.);部分缺失日期(如只有年月)的 imputation 规则也由参数控制。derive_vars_duration(trunc_out = TRUE):由 BRTHDT/TRTSDT 算 AGE 并向下取整——就是AGE = int((TRTSDT - BRTHDT) / 365.25)的验证版。ITTFL = if_else(!is.na(TRTSDT), "Y", "N"):治疗开始日期非缺失即入 ITT 分析集,对应 SAS 的if TRTSDT ne . then ITTFL = 'Y'; else ITTFL = 'N';。52 例 Screen Failure 全部 ITTFL='N'。count(ARM, ITTFL):一条管道顶PROC FREQ tables ARM*ITTFL;86+84+84=254 就是后面 Table 1 的分母。select(...):ADSL 只保留 spec 定义的 14 列——分析数据集"按需裁剪",与 SAS 里 keep= 一个道理。
步骤 3:推导 ADAE 与首例发生标志
ADAE 是记录级数据集(一行一条 AE),核心衍生是 AOCC01FL(每受试者首次 AE 发生标志),推导函数同样在 common.R:
derive_adae <- function() {
pharmaversesdtm::ae %>%
derive_vars_dt(new_vars_prefix = "AST", dtc = AESTDTC) %>%
derive_vars_dt(new_vars_prefix = "AEN", dtc = AEENDTC) %>%
derive_var_extreme_flag(
by_vars = exprs(USUBJID), new_var = AOCC01FL,
order = exprs(ASTDT), mode = "first"
) %>%
mutate(AEBODSYS = as.character(AEBODSYS)) %>%
select(USUBJID, AEBODSYS, AEDECOD, ASTDT, AENDT, AOCC01FL)
}
cat("\n== 步骤 3:admiral 推导 ADAE(含 AOCC01FL 首次发生标志)==\n")
adae <- derive_adae()
cat("AOCC01FL='Y' 行数:", sum(adae$AOCC01FL == "Y", na.rm = TRUE),
"(应等于受试者数", n_distinct(adae$USUBJID), "中有 AE 者)\n")
print(adae %>% count(AEBODSYS) %>% slice_max(n, n = 5))
# 实跑捕获(R 4.5.0) == 步骤 3:admiral 推导 ADAE(含 AOCC01FL 首次发生标志)== AOCC01FL='Y' 行数: 225 (应等于受试者数 225 中有 AE 者) # A tibble: 5 x 2 AEBODSYS n <chr> <int> 1 GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS 292 2 SKIN AND SUBCUTANEOUS TISSUE DISORDERS 276 3 NERVOUS SYSTEM DISORDERS 101 4 CARDIAC DISORDERS 91 5 GASTROINTESTINAL DISORDERS 87
逐行解读:
derive_var_extreme_flag(by_vars = USUBJID, order = ASTDT, mode = "first"):按受试者分组、按 AE 开始日期排序,给每人最早的一条 AE 打 'Y'。SAS 等价写法你闭着眼都会:sort; by USUBJID ASTDT; if first.ASTDT then AOCC01FL='Y';——admiral 把这套 by 组心智包成了带验证的函数。- 自检句写得好:
sum(AOCC01FL=="Y")应等于n_distinct(USUBJID)(225 = 225)。把"预期关系"直接写进日志,就是 SAS 里%put断言的习惯。 count(AEBODSYS) %>% slice_max(n, n = 5):SOC 频数 Top5,等价于 PROC FREQ 后接排序取前 5;皮肤与给药部位反应 276 条,符合胆碱能药物的药理预期。
步骤 4:rtables 出 Table 1
cat("\n== 步骤 4:rtables 出 Table 1(按 ARM 分层)==\n")
adsl_it <- adsl %>% filter(ITTFL == "Y")
tbl <- basic_table() %>%
split_cols_by("ARM") %>%
split_rows_by("SEX", split_fun = drop_split_levels) %>%
analyze("AGE", afun = function(x) {
in_rows(
"n" = length(x),
"Mean (SD)" = c(mean(x), sd(x)),
.formats = c("n" = "xx", "Mean (SD)" = "xx.x (xx.xx)")
)
}) %>%
build_table(adsl_it)
print(tbl)
# 实跑捕获(R 4.5.0)
== 步骤 4:rtables 出 Table 1(按 ARM 分层)==
Placebo Xanomeline High Dose Xanomeline Low Dose
----------------------------------------------------------------------
F
n 53 40 50
Mean (SD) 76.4 (8.73) 74.7 (7.67) 75.7 (8.09)
M
n 33 44 34
Mean (SD) 73.4 (8.15) 74.1 (8.16) 75.6 (8.69)
逐行解读:
filter(ITTFL == "Y"):Table 1 只统计 ITT 人群,52 例筛败被排除——分析集过滤先行,spec 优先,SAS 里就是where ITTFL='Y'。split_cols_by("ARM"):列方向按治疗组展开,对应 PROC TABULATE 的across维度;Screen Failure 组因 ITTFL='N' 自然不出现。split_rows_by("SEX", split_fun = drop_split_levels):行方向按性别分层,并丢弃空水平——类比 CLASS 变量配合MISSING=''的清理工作。analyze("AGE", afun = ...):自定义分析函数,in_rows()一次返回 "n" 与 "Mean (SD)" 两行,.formats直接用 shell 里的格式串("xx.x (xx.xx)")——表格规格与代码一一对应,审 spec 的人一眼看懂。print(tbl):rtables 的表对象是结构化的(行树 × 列树),不是文本流;控制台打印只是预览,同一对象可导出 RTF/PDF、可被 testthat 断言单元格数值。
SAS 误区:用 PROC TABULATE 的思路找"语句"——rtables 没有
table 语句,它是声明式管道:先描述表结构(split),再挂统计函数(analyze),最后 build。心智从"过程调用"换成"搭表骨架",复杂 shell(多级表头、嵌套行)反而比 SAS 好写。步骤 5:diffdf 双编程自检
cat("\n== 步骤 5:双编程自检(diffdf)==\n")
adsl_re <- derive_adsl()
res <- diffdf(adsl, adsl_re, keys = "USUBJID")
cat("diffdf 发现问题:", diffdf_has_issues(res), "\n")
cat("== C1 完成 ==\n")
# 实跑捕获(R 4.5.0) == 步骤 5:双编程自检(diffdf)== diffdf 发现问题: FALSE == C1 完成 ==
逐行解读:
- 教学脚本里
adsl_re是同函数重跑(演示接口);真实项目中它应来自第二位程序员独立编写的实现——双编程的价值在"独立",不在"重跑"。 diffdf(target, compare, keys = "USUBJID"):就是PROC COMPARE BASE=adsl COMPARE=adsl_re ID USUBJID——按键对齐、逐变量逐行比对,属性(类型/长度)差异也会报。diffdf_has_issues(res)返回 FALSE 即"零差异"放行;有差异时res按变量分块列出目标值/比对值/行键,比 PROC COMPARE 的 OUT= 数据集可读得多,可直接写入 QC 报告。
测验:C1 实跑输出中 AOCC01FL='Y' 共 225 行,这个数字的正确解读是?
选 B。derive_var_extreme_flag 按 USUBJID 分组、按 ASTDT 排序取 mode="first",每位有 AE 的受试者只标一行,故 'Y' 行数 = n_distinct(USUBJID) = 225;ae 全量其实是 1191 行。SAS 等价写法是排序后
by USUBJID ASTDT; if first.ASTDT then AOCC01FL='Y';。AOCC01FL 常用于"每受试者首次事件"口径的汇总,避免同一人被重复计数。解读要点
- admiral = R 世界的标准宏库:derive_* 函数带文档、带测试、版本受控,比"祖传 SAS 宏"更经得起审计——这正是第 5 章包验证工作流的入口。
- 分母纪律:Table 1 的 254(而非 306)来自 ITTFL='Y' 过滤;筛败人群的去留规则在 SAS 与 R 中完全一致,一切以 spec 为准。
- 双编程心智零平移:diffdf(keys=USUBJID) 即 PROC COMPARE ID USUBJID;差异必须清零并留档,R 只是换了工具,纪律没变。
- 表对象结构化:rtables 产出的是可编程对象,能导出 RTF/PDF、能被单测断言单元格值——这是 ODS 文本流做不到的。
练习
- (易)给 ADSL 加一个衍生字段 RACEGRP:把 RACE 合并为 "WHITE"/"ASIAN OR PACIFIC ISLANDER"/"其他" 三组(提示:
mutate + case_when),然后count(RACEGRP)验证三组频数合计等于 306。 - (中)用 adae 与 rtables 加一张按 SOC 的 AE 频数表(经典 Table 14.1 风格):行 = AEBODSYS(按总频数降序)、列 = ARM、单元格输出发生该 SOC 下任意 AE 的受试者数 n (%)——分母用各 ARM 的 ITT 人数,注意先去重到受试者级。
- (难)写双编程比对脚本:不看 common.R,用 base R 或自己的 dplyr 写法独立实现第二份 ADSL 推导,用
diffdf(keys="USUBJID")比对两份结果并把差异报告写入 outputs/(有差异时需列出变量名与行键清单,格式自拟)。
扩展阅读
- scda(pharmaverse 合成试点研究数据,GitHub) — 更完整的合成 SDTM/ADaM 试点数据集,适合练全链路 数据
- pharmaverse examples — admiral + rtables 的端到端示例工作流(ADSL/ADAE/ADTTE 到各类 TLF) 实战
下一案例:从 CDISC 主干道拐进统计分析——用 survival 包复刻 PROC LIFETEST 与 PROC PHREG 的经典组合。