R 临床实战From SAS to R, In Depth
全书目录 / 第 2 章
02

数据管理与 CDISC:SDTM/ADaM 的 R 实现

你对 CDISC 的理解已经胜过 R 生态里的大多数人,缺的只是一种 R 的表达方式。本章把 SDTM/ADaM 逐项搬进 R 世界:pharmaversesdtm 给数据,admiral 做衍生,metacore 管 spec,diffdf 做双编程。

2.1 数据三源:练习数据从哪来

SAS 里你练手用的是 CDISC Pilot 那套永久库;R 世界没有 libname,练习数据以包的形式分发——装包即建库,加载即 SET。三个最常用的来源:

  • pharmaversesdtm:pharmaverse 家族自带的合成 SDTM 数据集(基于 CDISC Pilot 研究,正是你熟悉的那份数据),含 dm、ae、ex、vs、lb 等 60 余个域,是本书的默认练习数据。
  • scda(Synthetic CDISC Datasets for Application,insightsengineering/Roche 出品,见 GitHub 仓库):按年度归档的合成 SDTM + ADaM 成套数据,例如装 scda2022 后用 scda2022::get_data("dm") 取数。年度归档 + 版本固定,适合企业做回归测试与验证,概念上相当于"每年冻结一版的标准测试库"。
  • synadam:CRAN 上的合成 ADaM 数据包,包内 extdata/ 直接带 adsl.csv、adae.csv、adlb.csv,配合 admiral 的 use_adam() 工作流使用。
library(pharmaversesdtm)
library(dplyr, warn.conflicts = FALSE)

# 包内自带合成 SDTM 数据(基于 CDISC Pilot 研究)
data(dm, package = "pharmaversesdtm")   # 等价于 dm <- pharmaversesdtm::dm

cat("dim:", dim(dm), "\n")              # 行 x 列
glimpse(dm[, c("USUBJID", "ARM", "SEX", "AGE", "BRTHDTC", "RFXSTDTC")])
# 实跑捕获(R 4.5.0)
dim: 306 28 
Rows: 306
Columns: 6
$ USUBJID  <chr> "01-701-1015", "01-701-1023", "01-701-1028", "01-701-1033", "…
$ ARM      <chr> "Placebo", "Placebo", "Xanomeline High Dose", "Xanomeline Low…
$ SEX      <chr> "F", "M", "M", "M", "F", "F", "F", "M", "F", "M", "M", "M", "…
$ AGE      <dbl> 63, 64, 71, 74, 77, 85, 59, 68, 81, 84, 52, 84, 81, 57, 75, 5…
$ BRTHDTC  <chr> "1950-12-26", "1948-07-22", "1942-07-11", "1940-03-10", "1937…
$ RFXSTDTC <chr> "2014-01-02", "2012-08-05", "2013-07-19", "2014-03-18", "2014…
library(synadam)

# synadam 的合成 ADaM 以 CSV 形式放在包内 extdata/
adsl_path <- system.file("extdata", "adsl.csv", package = "synadam")
adsl_syn  <- read.csv(adsl_path)

cat("dim:", dim(adsl_syn), "\n")
cat("vars:", paste(head(names(adsl_syn), 12), collapse = ", "), "...\n")
print(head(adsl_syn[, c("USUBJID", "TRT01A", "AGE", "SEX")], 3))
# 实跑捕获(R 4.5.0)
dim: 12 21 
vars: STUDYID, USUBJID, SUBJID, SITEID, TRT01A, TRT01AN, AGE, AGEU, SEX, RACE, ETHNIC, SAFFL ...
           USUBJID              TRT01A AGE SEX
1 CDISCPILOT01-001             Placebo  63   F
2 CDISCPILOT01-002             Placebo  71   M
3 CDISCPILOT01-003 Xanomeline Low Dose  55   F
逐行解读:
  1. data(dm, package = "pharmaversesdtm"):把包内数据集加载进当前环境,心智上等于 SET sdtm.dm——只不过"库"是只读的安装包,加载后是你内存里的私有副本,随便改不怕污染原件。
  2. dim():返回"行数 列数",等价于 PROC CONTENTS 首行的 Observations/Variables。
  3. glimpse():dplyr 提供的"竖排 PROC CONTENTS"——每个变量一行,显示类型(<chr>/<dbl>)与前几个值;宽数据集(SDTM 动辄几十列)用它比 str() 舒服得多。
  4. system.file("extdata", ...):定位包安装目录下的文件,相当于用 SAS 的 %sysfunc(pathname(...)) 找物理路径;synadam 的 ADaM 数据就在这里。
scda 是"概念块":本机未安装且需要网络,正文只介绍用法。真实项目里三者可混用——pharmaversesdtm 做日常教学与单元测试,scda 做版本化回归,synadam 做 admiral 模板演练。

2.2 SDTM 在 R 中的样子

SDTM 的语义你全懂,这里只回答一个问题:同一份 DM 域,在 R 里"看"起来什么样?关键变量与 SAS 心智的对照:

SDTM 变量R 类型SAS 心智对照
USUBJIDcharacter合并主键;等价于 MERGE ... BY USUBJID 的 BY 变量,R 里是 join 的 by=
ARM / ARMCDcharacter治疗组标签/编码值。SAS 常靠 format 关联,R 里通常保持字符型,分析时转 factor 定组序
RFXSTDTC / RFXENDTCcharacterISO 8601 字符日期,与 SAS SDTM 完全一致;进入分析层前必须转成 Date 类型
BRTHDTCcharacter同上;转成 BRTHDT 后才能算年龄
AGE / AGEUdouble / character数值年龄 + 单位;对应 SAS 的 8 字节数值与 "YEARS"
DTHFLcharacter("Y" 或 NA)标志变量。注意:非"Y"时是 NA,不是 SAS 的空字符串 " "
library(pharmaversesdtm)
library(dplyr, warn.conflicts = FALSE)
dm <- pharmaversesdtm::dm

# SAS 心智:proc print data=dm(obs=4) keep=...
dm %>%
  select(USUBJID, ARMCD, ARM, SEX, AGE, AGEU, BRTHDTC, RFXSTDTC, RFXENDTC, DTHFL) %>%
  slice_head(n = 4) %>%
  as.data.frame() %>%
  print()
# 实跑捕获(R 4.5.0)
      USUBJID  ARMCD                  ARM SEX AGE  AGEU    BRTHDTC   RFXSTDTC
1 01-701-1015    Pbo              Placebo   F  63 YEARS 1950-12-26 2014-01-02
2 01-701-1023    Pbo              Placebo   M  64 YEARS 1948-07-22 2012-08-05
3 01-701-1028 Xan_Hi Xanomeline High Dose   M  71 YEARS 1942-07-11 2013-07-19
4 01-701-1033 Xan_Lo  Xanomeline Low Dose   M  74 YEARS 1940-03-10 2014-03-18
     RFXENDTC DTHFL
1 2014-07-02  <NA>
2 2012-09-01  <NA>
3 2014-01-14  <NA>
4 2014-03-31  <NA>
逐行解读:
  1. pharmaversesdtm::dm:包名::对象名 直接取数,不加载整个包——相当于 sdtm.dm 这种"库.成员"写法,只是库换成了包。
  2. select(...):挑列,等价于 KEEP=;slice_head(n = 4) 等价于 OBS=4。
  3. %>% 是管道:把上一步结果递给下一个函数,SAS 里最接近的心智是 DATA step 的连续语句,但每步都是完整表达式。
  4. as.data.frame() %>% print():pharmaversesdtm 的数据是 tibble(打印时会截断列并显示类型);转成普通 data.frame 再打印,得到类似 SAS listing 的完整展示。
SAS 误区:R 没有 libname 引擎,"数据即对象"。library(包) ≈ 挂一个只读永久库,data() ≈ SET 进 work,写回递交格式用 haven::write_xpt()(XPT)。另一个坑:SDTM 的 ISO 字符日期在 R 里也应该保持字符型——别在 SDTM 层就地转 Date,那是 ADaM 层的事(下一节)。

2.3 admiral:从 SDTM 推 ADSL

admiral(ADaM in R)是 pharmaverse 的 ADaM 衍生引擎:上百个 derive_*() 函数,每个都对应一类 ADaM IG 衍生逻辑,文档里直接引用 IG 条目。心智上它就是"全行业共同维护、经过验证的 ADaM 标准宏库",只是形态从 %macro 变成了带断言检查的 R 函数。下面从 dm 出发推一个教学版 ADSL:

library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)
dm <- pharmaversesdtm::dm

adsl <- dm %>%
  select(-AGE, -AGEU) %>%
  # ISO 8601 字符日期 -> Date 型变量(前缀 + DT)
  derive_vars_dt(new_vars_prefix = "BRTH", dtc = BRTHDTC) %>%
  derive_vars_dt(new_vars_prefix = "TRTS", dtc = RFXSTDTC) %>%
  derive_vars_dt(new_vars_prefix = "TRTE", dtc = RFXENDTC) %>%
  # 年龄 = BRTHDT 到 TRTSDT 的时长,截断为整年
  derive_vars_duration(
    new_var = AGE, new_var_unit = AGEU,
    start_date = BRTHDT, end_date = TRTSDT,
    out_unit = "years", trunc_out = TRUE
  ) %>%
  # 研究日:以 TRTSDT 为参照,为每个源变量生成 <名>DY
  derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(TRTSDT, TRTEDT))

print(as.data.frame(adsl %>%
  select(USUBJID, BRTHDT, TRTSDT, AGE, AGEU, TRTSDY, TRTEDY) %>%
  head(5)))
# 实跑捕获(R 4.5.0)
      USUBJID     BRTHDT     TRTSDT AGE  AGEU TRTSDY TRTEDY
1 01-701-1015 1950-12-26 2014-01-02  63 years      1    182
2 01-701-1023 1948-07-22 2012-08-05  64 years      1     28
3 01-701-1028 1942-07-11 2013-07-19  71 years      1    180
4 01-701-1033 1940-03-10 2014-03-18  74 years      1     14
5 01-701-1034 1937-06-24 2014-07-01  77 years      1    183
逐行解读:
  1. select(-AGE, -AGEU):先丢掉 dm 自带的 AGE/AGEU 再重新推导——DATA step 里 SET dm(DROP=AGE AGEU) 的同款操作,目的是稍后能与原值对账。
  2. derive_vars_dt(new_vars_prefix = "TRTS", dtc = RFXSTDTC):把 ISO 字符日期转成真正的 Date 变量 TRTSDT(前缀 + DT)。日期不完整时可用 highest_imputation/date_imputation 参数做符合 ADaM IG 的填补,并自动生成 *DTF 填补标志。
  3. derive_vars_duration(...):AGE = BRTHDT → TRTSDT 的时长;out_unit = "years" 定单位,trunc_out = TRUE 对应 SAS 的 INT() 取整年龄,AGEU 同步生成。
  4. derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(TRTSDT, TRTEDT)):为 source_vars 里每个日期变量生成研究日 TRTSDY/TRTEDY;"参照日 = Day 1、没有 Day 0"的 SDTM/ADaM 规则已内置——所以 TRTSDY 恒为 1,TRTEDY 是治疗持续天数。
  5. exprs(...):rlang 的"未求值表达式列表",把变量名当名字而非值传进去——近似 SAS 宏传参数名清单的心智,第 5 章会展开 tidy evaluation。
SAS 误区:derive_vars_dy() 没有 new_var 参数——新变量名固定为"源变量名 + DY";且 source_vars 必须已经是 Date 型,直接塞 ISO 字符(如 RFXENDTC)会立刻报错。习惯先 derive_vars_dt 再 derive_vars_dy,两步走。
admiral 由 Roche、GSK、J&J、Merck 等公司联合维护,是 R Consortium / pharmaverse 生态的旗舰包;每个函数的帮助页(如 ?derive_vars_duration)都附 ADaM IG 出处和大量示例,遇到"这个衍生 IG 怎么规定的"先查它。

测验:在 admiral 中推导 TRTSDY、AEENDY 这类"研究日"变量(参照日 = Day 1,无 Day 0),应该用哪个函数族?

选 B。derive_vars_dy() 以 reference_date 为参照、按 source_vars 批量生成 *DY 变量,并内置"无 Day 0"规则;derive_vars_dt() 负责 ISO 字符日期转 Date,derive_vars_merged() 负责从其他数据集合并变量(相当于 MERGE/lookup)。

2.4 ADAE:日期转换与首发标志

ADAE 的两个高频动作:把 AESTDTC/AEENDTC 转成分析日期(含缺失填补与填补标志),以及打"每受试者首次发生"标志(AOCC01FL 家族)。admiral 各用一个函数解决:

library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)
ae <- pharmaversesdtm::ae

adae <- ae %>%
  derive_vars_dt(new_vars_prefix = "AST", dtc = AESTDTC,
                 highest_imputation = "M", flag_imputation = "date") %>%
  derive_vars_dt(new_vars_prefix = "AEN", dtc = AEENDTC,
                 highest_imputation = "M", flag_imputation = "date") %>%
  derive_var_extreme_flag(
    by_vars = exprs(USUBJID),
    order = exprs(ASTDT, AESEQ),
    new_var = AOCC01FL,
    mode = "first"
  )

print(as.data.frame(adae %>%
  select(USUBJID, AESEQ, AEDECOD, ASTDT, ASTDTF, AOCC01FL) %>%
  filter(USUBJID == "01-701-1015")))
cat("AOCC01FL: Y =", sum(adae$AOCC01FL == "Y", na.rm = TRUE),
    "; 总 AE =", nrow(adae), "; 受试者数 =", n_distinct(adae$USUBJID), "\n")
# 实跑捕获(R 4.5.0)
      USUBJID AESEQ                   AEDECOD      ASTDT ASTDTF AOCC01FL
1 01-701-1015     1 APPLICATION SITE ERYTHEMA 2014-01-03   <NA>        Y
2 01-701-1015     2 APPLICATION SITE PRURITUS 2014-01-03   <NA>     <NA>
3 01-701-1015     3                 DIARRHOEA 2014-01-09   <NA>     <NA>
AOCC01FL: Y = 225 ; 总 AE = 1191 ; 受试者数 = 225
逐行解读:
  1. derive_vars_dt(new_vars_prefix = "AST", dtc = AESTDTC):生成 ASTDT;highest_imputation = "M" 允许对"只有年"的日期做到月级填补,flag_imputation = "date" 生成 ADaM IG 要求的 ASTDTF 填补标志列(本数据日期完整,故全为 NA)。
  2. derive_var_extreme_flag(by_vars = exprs(USUBJID), order = exprs(ASTDT, AESEQ), ...):在每个受试者组内按 ASTDT(平手时用 AESEQ)排序,mode = "first" 给第一条记录打 AOCC01FL = "Y",其余为 NA。
  3. filter(USUBJID == "01-701-1015"):等价于 WHERE USUBJID = "01-701-1015",抽查一个受试者的三条 AE——同日的两条里只有 AESEQ=1 那条拿到 Y。
  4. 末行自查:Y 的个数(225)= 受试者数(225),每人恰好一个首发标志,逻辑自洽。这种"计数对账"应当写进你的 QC 脚本。
SAS 误区:SAS 打首发标志要 PROC SORT 后在 DATA step 里 BY USUBJID ASTDT; IF FIRST.USUBJID;R 里不需要物理排序数据,order 只是声明。但若 order 存在平手(同日两条 AE)而你不写 AESEQ 这样的 tie-breaker,打标结果就依赖原始行序——双编程比对时两边可能各打各的,差异查半天。规则:order 必须能唯一定序。

2.5 ADTTE:时间至事件推导

TTE(Time-to-Event)是 SAS 程序员最熟悉的"多源取最早日期 + 删失"逻辑。admiral 的做法是声明式的:把每个事件/删失的来源写成 event_source()/censor_source() 对象,再交给 derive_param_tte() 统一裁决。下面是一个最小可跑的 OS(总生存)骨架:

library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)
dm <- pharmaversesdtm::dm

# 最小 ADSL:治疗开始日、死亡日、最后已知存活日
adsl <- dm %>%
  derive_vars_dt(new_vars_prefix = "TRTS",   dtc = RFXSTDTC) %>%
  derive_vars_dt(new_vars_prefix = "DTH",    dtc = DTHDTC) %>%
  derive_vars_dt(new_vars_prefix = "LSTALV", dtc = RFENDTC)

# 事件源:死亡(CNSR = 0)
death <- event_source(
  dataset_name = "adsl",
  filter = DTHFL == "Y",
  date = DTHDT,
  set_values_to = exprs(EVNTDESC = "DEATH", CNSR = 0)
)

# 删失源:最后已知存活日(CNSR = 1)
lstaet <- censor_source(
  dataset_name = "adsl",
  filter = TRUE,
  date = LSTALVDT,
  set_values_to = exprs(EVNTDESC = "LAST DATE KNOWN ALIVE", CNSR = 1)
)

adtte <- derive_param_tte(
  dataset_adsl = adsl,
  source_datasets = list(adsl = adsl),
  start_date = TRTSDT,
  event_conditions = list(death),
  censor_conditions = list(lstaet),
  create_datetime = FALSE,
  set_values_to = exprs(PARAMCD = "OS", PARAM = "Overall Survival")
) %>%
  # TTE 天数:AVAL = ADT - STARTDT
  derive_vars_duration(new_var = AVAL, start_date = STARTDT, end_date = ADT)

print(as.data.frame(adtte %>%
  select(USUBJID, PARAMCD, AVAL, CNSR, EVNTDESC) %>%
  filter(CNSR == 0 | row_number() <= 5)))
cat("--- CNSR 分布(0 = 事件/死亡,1 = 删失)---\n")
print(table(adtte$CNSR))
# 实跑捕获(R 4.5.0)
      USUBJID PARAMCD AVAL CNSR              EVNTDESC
1 01-701-1015      OS  182    1 LAST DATE KNOWN ALIVE
2 01-701-1023      OS   29    1 LAST DATE KNOWN ALIVE
3 01-701-1028      OS  180    1 LAST DATE KNOWN ALIVE
4 01-701-1033      OS   28    1 LAST DATE KNOWN ALIVE
5 01-701-1034      OS  183    1 LAST DATE KNOWN ALIVE
6 01-701-1211      OS   61    0                 DEATH
7 01-704-1445      OS  175    0                 DEATH
8 01-710-1083      OS   12    0                 DEATH
--- CNSR 分布(0 = 事件/死亡,1 = 删失)---

  0   1 
  3 251 
逐行解读:
  1. event_source(dataset_name, filter, date, set_values_to):一份"数据来源声明"——从 source_datasets 列表里名为 "adsl" 的数据集取数,筛 DTHFL == "Y" 的行,取其 DTHDT 作事件日期,并给输出赋 EVNTDESC 与 CNSR=0。相当于把 SAS 里某一路 merge 支流的 WHERE + 赋值逻辑预先封装成对象。
  2. censor_source(...):结构与事件源相同,但 filter = TRUE(人人都有"最后已知存活日")、CNSR=1。
  3. derive_param_tte(...):对每个受试者,在全部事件源里取最早的事件日期;没有事件则用删失源。输出 STARTDT(起点 TRTSDT)、ADT(事件或删失日期)、EVNTDESC、CNSR,PARAMCD/PARAM 由 set_values_to 赋值。
  4. 注意 AVAL 不是自动生成的:TTE 天数要再接一步 derive_vars_duration(new_var = AVAL, start_date = STARTDT, end_date = ADT)。
  5. 结果 254 行 < dm 的 306 行:52 个筛选失败者没有 RFXSTDTC → TRTSDT 为 NA → 被自动排除。这符合"未治疗者不进 TTE 分析"的惯例,但你要知道它发生了——数一下行数永远是好习惯。
真实项目直接用 admiral 随包分发的 ADaM TTE 模板脚本:多参数(OS/PFS/特定 AE 事件)时给每个 event_source 配不同的 PARAMCD,并把 adae、adrs 等多个数据集一起放进 source_datasets。第 6 案例(C2 生存分析)会用它跑完整流程。

2.6 metadata 驱动:metacore 与 metatools

SAS 世界的 spec 活在 Excel 里,靠人眼执行;pharmaverse 的方向是 spec 即代码:metacore 把映射 spec 解析成机器可读的元数据对象,metatools 拿它来程序化地校验数据、补标签、按 spec 排序变量——相当于把"对照 define/spec 人工检查"这一步自动化。

library(metacore)
library(metatools)
quiet <- function(expr) suppressWarnings(suppressMessages(expr))

# Excel spec(P21 风格,包内示例)-> Metacore 对象
mc <- quiet(spec_to_metacore(metacore_example("p21_mock.xlsx")))
mc

# 取出 DM 域的元数据子集
dm_spec <- quiet(select_dataset(mc, dataset = "DM"))
cat("DM spec 变量数:", nrow(dm_spec$var_spec), "\n")
print(as.data.frame(dm_spec$var_spec[1:5, c("variable", "label", "type", "length")]))
# 实跑捕获(R 4.5.0)
── Metacore object contains metadata for 5 datasets ────────────────────────────
→ AE (Adverse Events)
→ DM (Demographics)
→ EX (Exposure)
→ SUPPAE (Supplemental Qualifiers for AE)
→ SUPPDM (Supplemental Qualifiers for DM)

To use the Metacore object with metatools package, first subset a dataset using
`metacore::select_dataset()`
DM spec 变量数: 25 
  variable                             label type length
1  STUDYID                  Study Identifier text     12
2   DOMAIN               Domain Abbreviation text      2
3  USUBJID         Unique Subject Identifier text     11
4   SUBJID  Subject Identifier for the Study text      4
5  RFSTDTC Subject Reference Start Date/Time date     10
逐行解读:
  1. spec_to_metacore():读入 Excel spec,解析出数据集清单、变量定义(var_spec)、受控术语(codelists)、衍生规则等,装进一个 Metacore 对象;示例文件 p21_mock.xlsx 随包分发,覆盖 AE/DM/EX/SUPP--。
  2. select_dataset(mc, dataset = "DM"):metatools 的函数一次只处理一个域,先取出 DM 子集。
  3. dm_spec$var_spec:机器可读的 spec 行——变量名、标签、类型、长度,与 Excel 里你逐行核对的那些列一一对应。
  4. 拿到 var_spec 后,metatools::add_labels() 给数据打变量标签、check_ct_col() 查受控术语合规、order_cols() 按 spec 排列列序、sort_by_key() 按主键排序——都直接以 spec 为准,不再抄 Excel。
现阶段 metacore/metatools 更适合"读 spec 做校验与标注",完整的 spec→数据流水线仍在演进;企业实践常把它与 admiral 模板、testthat 断言组合。入门先跑通上面这个小例子,再读 metatools 的 vignette。

2.7 双编程比对:diffdf

双编程纪律在 R 世界原样保留,只是 PROC COMPARE 换成了 diffdf。构造两份"独立编程"的 ADSL(这里用副本故意改一处 AGE 模拟差异),按主键逐格比对:

library(diffdf)
library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)

adsl <- pharmaversesdtm::dm %>%
  derive_vars_dt(new_vars_prefix = "TRTS", dtc = RFXSTDTC) %>%
  select(USUBJID, SEX, AGE, TRTSDT) %>%
  slice_head(n = 20)

# 模拟"复现程序员"的独立结果:故意制造 1 处数值差异
adsl_comp <- adsl
adsl_comp$AGE[3] <- adsl_comp$AGE[3] + 1

res <- diffdf(adsl, adsl_comp, keys = "USUBJID")
print(res)
cat("是否存在差异:", diffdf_has_issues(res), "\n")
# 实跑捕获(R 4.5.0,展示有删减)
In diffdf(adsl, adsl_comp, keys = "USUBJID") : 
Not all Values Compared Equal
Differences found between the objects!

Summary of BASE and COMPARE
  ==================================================================
    PROPERTY             BASE                       COMP            
  ------------------------------------------------------------------
      Name               adsl                     adsl_comp         
    Rows(#)               20                         20             
   Columns(#)              4                          4             

Not all Values Compared Equal
  =============================
   Variable  No of Differences 
  -----------------------------
     AGE             1         

  ======================================
   VARIABLE    USUBJID    BASE  COMPARE 
  --------------------------------------
     AGE     01-701-1028   71     72    
是否存在差异: TRUE
逐行解读:
  1. diffdf(base, compare, keys = "USUBJID"):对标 PROC COMPARE 的 ID USUBJID——按主键对齐两份数据后逐变量、逐格比较,同时对行数、列数、变量类型/属性做整体核查。
  2. 打印报告分三段:BASE/COMPARE 属性摘要、按变量汇总的差异计数、逐条差异明细(带主键值与两边的实际取值)——正好对应 PROC COMPARE 日志里你习惯扫的那几块。
  3. diffdf_has_issues(res):返回 TRUE/FALSE,可以直接塞进 testthat::expect_false() 断言或 CI 步骤,让"比对不过"自动红灯,而不是靠人读日志。
  4. 备选:arsenal::compare(adsl, adsl_comp, by = "USUBJID") 同样按主键比对,输出可定制成表格/HTML 报告,对 tolerance、列序、变量忽略的控制更细——一句带过,需要漂亮比对报告时想它。
SAS 误区:PROC COMPARE 的 criterion=(EXACT/RELATIVE/ABSOLUTE)与 outnoeq 那套心智,在 diffdf 里对应 tolerance = 参数(数值绝对容差)。两个方向的坑:浮点衍生变量(两位程序员用不同算法算出的 AVAL)不设 tolerance 会满屏"伪差异";反过来,AGE、AESEQ 这类整型变量不要习惯性放 tolerance——0.99 岁的差异会被静默放过,那就是真漏检了。按变量类型分别决定容差,并写进比对方案。

2.8 CDISC CORE:把 conformance 变成代码

CDISC CORE(Conformance Rule Engine)是 CDISC 官方的开源规则引擎:把 SDTM/ADaM/Define-XML/受控术语的 conformance 规则写成机器可执行的规则代码,配合可视化的 CORE Editor 在递交前对数据集做合规校验——相当于把 Pinnacle 21 类检查的核心逻辑开放化、可协作化。

  • 规则即代码:每条规则是可版本控制、可测试、可复用的工件,而不是散落在核查计划文档里的一句话;社区可以直接给规则提 PR。
  • 标准机器可读化:CT(受控术语)、IG 规则都以结构化形式发布,程序(包括你本章写的 R 校验脚本)可以直接消费。
  • 与 AI 结合的趋势:当规则与数据标准都机器可读后,"AI 辅助数据清洗、自动生成校验、解释 conformance finding"就有了可靠的地基——这也是 PharmaSUG 近两年 DS/SS/SI 专题的主线之一(见章末本地精读)。
对 SAS Programmer 的含义:你写的每一条 IF ... THEN PUT "ERROR: ..." 式核查,未来都可能对应 CORE 里一条现成规则;学 R 校验栈(diffdf + testthat + metatools)的同时,值得把 CORE 的规则库当"核查计划模板"翻一翻。

章末资源

本章练习

  1. (易)在 2.3 的 adsl 基础上再衍生一个字段:用 derive_vars_duration() 从 TRTSDT/TRTEDT 算出治疗持续天数 TRTDURD,并抽查前 5 行与 TRTEDY 的关系。
  2. (中)给 2.4 的 adae 增加 AOCC02FL(每受试者每 PT 的首次发生标志):提示是把 by_vars 扩成 exprs(USUBJID, AEDECOD),做完用计数对账——Y 的总数应等于"受试者 × 出现过的 PT"组合数。
  3. (难)写一个可复用的双编程比对脚本:函数形式接收两份 ADSL(csv/xpt 路径)、主键与按变量的 tolerance 清单,内部调用 diffdf(),有差异时打印明细并以非零状态退出(quit(status = 1)),无差异时输出一行"COMPARE PASSED"——这就是能挂进 CI 的最小比对闸门。

下一章:统计分析——PROC FREQ、PROC LIFETEST、PROC MIXED 里你天天跑的假设检验、生存分析与混合模型,在 R 里怎么做、怎么验、怎么进递交。