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

teal 探索器:把 ADaM 装进可过滤、可复现的交互应用

SAS 的探索方式是"改 where 语句、重跑、翻 ODS 输出";teal 的方式是"左侧拉过滤器、右侧即时重算、代码还能导出复现"。本案例复用 C1 的 ADaM 推导,用 pharmaverse 的 teal 框架组装一个多模块数据浏览器,并借它讲清 reactive(反应式)这个 Shiny 核心概念。

C6.1 背景与学习目标

teal 是 pharmaverse 生态(与 admiral、pharmaversesdtm 同源,见 C1)的 Shiny 应用框架,专为临床数据探索与交互式 TLF 审阅设计:数据装在 teal_data 容器里,过滤器面板生成的筛选与派生代码全部记录为可导出的 R 表达式——探索结果可复现,这一点直击监管对"数据切片从哪来"的审计需求。R 大厂(Roche 等)已用它做内部数据浏览。

学完本案例,你应能:

  1. 复用 cases/common.R 的 ADaM 推导产出 ADSL/ADAE,并装入 teal_data() 容器、用 within() 在容器内做合并派生;
  2. 用 init(data, modules) 组装 teal 应用,看懂自定义 module() 的三件套:ui / server / datanames;
  3. 理解模块 server 里的 data 是 reactive 函数——写 data() 才能拿到"过滤面板当前筛选后"的数据,过滤器一变图表自动重算。

C6.2 数据源

数据源:复用 C1 案例的 ADaM 推导——source("cases/common.R") 获得 derive_adsl() 与 derive_adae();上游数据来自 pharmaversesdtm 包内置的合成 SDTM 试验数据(dm / ae 数据集,非真实受试者),基于 admiral 标准推导生成 ADSL(受试者水平,含 ITTFL 过滤)与 ADAE(不良事件水平)。全离线可跑,无网络依赖。

C6.3 环境:复用 common.R

# C6:teal 临床数据探索器(复用 C1 的 ADaM 推导;非交互模式打印结构)
# 运行:Rscript cases/c6_teal_explorer.R ;交互:R 会话内 source 后 shiny::runApp(app)
source(file.path(if (grepl("cases$", getwd())) ".." else ".", "cases", "common.R"))
suppressPackageStartupMessages({ library(teal); library(shiny) })
数据推导逻辑与 C1 共用一个 common.R——静态 Table 1 和交互探索器吃的是同一份 ADaM。这就是 SAS 宏库复用思想在 R 里的对应:一处推导,多处消费,口径天然一致。

C6.4 步骤 1:构造 teal_data 输入

cat("== 步骤 1:构造 ADaM 输入 ==\n")
adsl <- derive_adsl() %>% filter(ITTFL == "Y")
adae <- derive_adae()
td <- teal_data(ADSL = adsl, ADAE = adae)
td <- within(td, {
  ADAE <- merge(ADAE, ADSL[c("USUBJID", "AGE", "SEX")], by = "USUBJID")
})
cat("datanames:", paste(datanames(td), collapse = ", "), "\n")
# 实跑捕获(R 4.5.0)
== 步骤 1:构造 ADaM 输入 ==
datanames: ADAE, ADSL
逐行解读:
  1. filter(ITTFL == "Y"):只带 ITT 人群进应用——探索器的分析人群策略应在数据入口处定死,而不是留给每个用户临场筛。
  2. teal_data(ADSL = adsl, ADAE = adae):不是普通 list!它是"数据 + 代码环境"的容器:装入时的表达式、之后的每次派生都被记录为可复现代码,对应递交语境里"数据集 + 生成程序"必须成对的审计要求。
  3. within(td, { ADAE <- merge(...) }):在容器内部派生——把 ADSL 的 AGE/SEX 合并进 ADAE,让 AE 模块能按人口学切片;这段代码同样被 teal 记录。
  4. datanames(td) 返回容器内数据集清单(按字母序 ADAE, ADSL),模块用它声明自己消费哪些数据。

C6.5 步骤 2:组装 teal 应用

cat("\n== 步骤 2:组装 teal 应用 ==\n")
app <- init(
  data = td,
  modules = modules(
    example_module(label = "通用表格示例"),
    module(
      label = "ADSL 年龄直方图",
      server = function(id, data) {
        moduleServer(id, function(input, output, session) {
          output$plot <- renderPlot({
            d <- data()[["ADSL"]]
            hist(d$AGE, main = "AGE at trial start", col = "#7d8f4e", border = "white")
          })
          renderUI(tags$p("按 ARM 查看请配合 filter 面板使用。"))
        })
      },
      ui = function(id) {
        ns <- NS(id)
        tags$div(plotOutput(ns("plot")), uiOutput(ns("txt")))
      },
      datanames = "ADSL"
    )
  )
)
cat("app 类型:", class(app)[1], "\n")
# 实跑捕获(R 4.5.0)
== 步骤 2:组装 teal 应用 ==
app 类型: teal_app
逐行解读:
  1. init(data, modules) 是 teal 的总装线:数据容器 + 模块清单 → 一个可运行的 Shiny 应用对象(class 为 teal_app)。
  2. example_module() 是 teal 自带的示例模块,一行就能挂进应用——先用现成模块跑通,再逐步替换成自己的。
  3. 自定义 module() 三件套:ui(id) 画界面(用 NS(id) 命名空间避免输出 ID 冲突)、server(id, data) 写逻辑、datanames 声明消费 "ADSL"——teal 据此只把需要的数据集交给该模块。
  4. data()[["ADSL"]]:注意那对括号!server 收到的 data 是 reactive 函数,调用 data() 才取到"过滤面板当前筛选后"的最新数据;把它写在 renderPlot({...}) 内部,过滤器一变,图自动重画。
  5. SAS 对照:等价于用户每改一次 where 条件,你不用重跑程序——teal 替你"自动重跑"了这个模块。

C6.6 步骤 3:模块清单(一次真实的"翻车")

cat("\n== 步骤 3:模块清单 ==\n")
lbls <- vapply(app$modules, function(m) m$label, character(1))
print(lbls)
# 实跑捕获(R 4.5.0)
== 步骤 3:模块清单 ==
character(0)
逐行解读:
  1. 本意是程序化读出各模块的 label,实跑却得到 character(0)(长度为 0 的字符向量)。
  2. 原因:init() 返回的 teal_app 本质是包装过的 Shiny appobj,并不以 $modules 字段暴露内部模块清单——对 NULL 做 vapply 就返回空向量。这不是 bug,而是"别依赖框架内部结构"的教训。
  3. 正确姿势:模块清单在组装前就是你自己写的 modules(...) 对象,想审计标签就在那里记录;组装后的 app 只适合拿去 runApp()。
  4. SAS 对照:如同想从编译好的 SAS 数据集里反读生成它的 DATA step 源码——产物不携带过程,过程要自己留档(这也是 teal_data 记录代码的价值所在)。

C6.7 交互运行守卫

if (interactive()) {
  cat("\n交互模式:启动应用……\n")
  shiny::runApp(app)
} else {
  cat("\n非交互模式:不启动服务器。交互运行请在 R 会话执行 shiny::runApp(app)。\n")
}
cat("== C6 完成 ==\n")
# 实跑捕获(R 4.5.0)
非交互模式:不启动服务器。交互运行请在 R 会话执行 shiny::runApp(app)。
== C6 完成 ==
逐行解读:
  1. interactive():区分"人在 R/RStudio 会话里"与"Rscript 批处理/CI 在跑"。批处理里若误启 Web 服务器,进程会永久挂住。
  2. 这个守卫让同一份脚本两用:CI 里跑冒烟测试(构建 app、验证结构不报错),桌面上 runApp(app) 即刻变交互应用——工程化 Shiny 的标配写法。
SAS 误区:想先把表格"像 ODS RTF 一样静态导出验证"再搬进 Shiny。teal 的价值恰恰是过滤 → 重算 → 代码导出的闭环:用户在过滤面板做的每个切片,都能一键导出成可复现的 R 代码交给双编程 QC。把静态 TLF 塞进 shiny 的 table 输出,等于放弃了 teal_data 的代码链,退回"结果无法溯源"的老问题。

C6.8 解读要点

  • 一处推导、两处消费:common.R 的 ADaM 同时喂 C1 的静态 Table 1 和本案例的交互探索器——口径一致性的工程保证,对应 SAS 宏库复用。
  • teal_data = 数据 + 代码环境:装入表达式、within() 派生、过滤面板操作全部记录为可导出的 R 代码,探索过程可复现、可审计。
  • module 三件套:ui(id) / server(id, data) / datanames;datanames 决定模块拿到哪些数据集,是 teal 的数据访问控制。
  • reactive 心智:server 里的 data 是函数不是值,data() 取"当前"数据;写在 render* 内部即获得"过滤器一变自动重算"——这是从 SAS 批处理思维转向 Shiny 的关键一步。
  • 非交互守卫:if (interactive()) runApp 让脚本同时服务 CI 冒烟测试与桌面交互运行;产物对象(teal_app)的内部结构不可依赖,审计信息要在组装前自留。

测验:在 teal 自定义模块的 server 里,为什么必须写 data()[["ADSL"]] 而不是 data[["ADSL"]]?

选 B。teal 传给模块 server 的第二个参数是一个 reactive(反应式函数),封装了"用户在左侧过滤面板筛选、模块在 teal_data 代码环境中重算之后"的数据快照。data() 每次调用返回当前值;把它写在 renderPlot/renderTable 内部,Shiny 的依赖追踪就会在过滤器变化时自动重新执行绘图代码。这正是 SAS 里"改 where 语句→重跑程序→看新输出"三步在 Shiny 里的自动化版本。teal_data 则是这一切的载体:数据 + 可复现代码环境。

C6.9 练习

  1. (易)给 example_module() 换参数重跑:改 label、指定 datanames = "ADAE",观察模块内容与数据入口的变化(参数清单见 ?teal.modules.general::example_module)。
  2. (中)仿照"ADSL 年龄直方图",新增一个"AE 按 SOC 条形图"模块:datanames = "ADAE",server 里 barplot(table(d$AEBODSYS))(记得 las = 2 或 horiz 处理长标签),挂进 modules() 后 runApp 验证。
  3. (难)把应用部署到 shinyapps.io(rsconnect 包),并写一份部署核对清单:renv 锁定依赖版本 → 入口 app.R(source 推导 + runApp)→ 本地非交互冒烟通过 → shinyapps.io 账号与 token 配置 → rsconnect::deployApp() → 打开线上 URL 验证过滤器与模块 → 记录部署日志与包版本清单。

C6.10 扩展阅读

  • teal 官方文档 — teal_data、init、filter 面板与代码导出的完整指南,含多篇 articles 进阶
  • teal.modules.clinical — 现成的临床 TLF 模块库(人口学表、AE 表、KM 曲线、森林图…),可直接挂进 init() 进阶
  • pharmaverse 生态门户 — admiral / teal / pharmaversesdtm 同源全家福,本书 C1 与第 7 章的底座 参考

案例部分到此收官。回到全书目录补齐章节知识,或读第 8 章看看这些技能在社区与职业市场上如何变现。