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

统计分析:从生存曲线到 MMRM

你已经会读 SAP、会审 TLF、知道 HR 与 p 值意味着什么。这一章只做一件事:把 PROC SGPLOT / LIFETEST / PHREG / MIXED / FREQ 换成 R 的等价物,并且让你逐行看懂输出——本章每个代码块都在 R 4.5.0 上实跑过,输出原样贴出。

3.1 ggplot2 的图层语法:把 SGPLOT 的心智拆开重装

SAS 里画图是"一句 PROC SGPLOT + 一堆选项":你告诉 SAS 画什么图(VBAR)、按什么分组(GROUP=)、怎么排版(ODS)。ggplot2 换了个思路——它不是"画图命令",而是图形语法(grammar of graphics):一张图由六个可独立替换的层叠起来。

先用一份自造的迷你 ADSL + ADAE 画 AE 系统器官分类(SOC)的分组条形图。数据是模拟的,结构是真的:USUBJID / TRT01P 在 ADSL,AEDECOD / AEBODSYS 在 ADAE,按 USUBJID 合并——和你在 SAS 里做的完全一样。

library(ggplot2)
set.seed(2024)
n <- 120
## 迷你 ADSL:120 例受试者,随机分到三组
adsl <- data.frame(
  USUBJID = sprintf("S%03d", seq_len(n)),
  TRT01P  = factor(rep(c("Placebo", "Drug 20mg", "Drug 50mg"), length.out = n),
                   levels = c("Placebo", "Drug 20mg", "Drug 50mg"))
)
## 迷你 ADAE:214 条 AE 记录,PT 归属 5 个 SOC
soc  <- c("Nervous system", "Gastrointestinal", "Skin", "General", "Respiratory")
pt   <- list(c("Headache", "Dizziness"), c("Nausea", "Diarrhoea"),
             c("Rash", "Pruritus"), c("Fatigue", "Pyrexia"), c("Cough"))
aeterm <- unlist(pt)
aesoc  <- rep(soc, sapply(pt, length))
ae <- data.frame(
  USUBJID  = sample(adsl$USUBJID, 214, replace = TRUE),
  AEDECOD  = sample(aeterm, 214, replace = TRUE)
)
ae$AEBODSYS <- aesoc[match(ae$AEDECOD, aeterm)]
ae <- merge(ae, adsl, by = "USUBJID")   # 等价于 SAS: merge ae adsl; by USUBJID;

## 四层就够出图:data -> mapping -> geom -> theme
p <- ggplot(ae, aes(x = AEBODSYS, fill = TRT01P)) +
  geom_bar(position = "dodge") +
  labs(x = "System Organ Class", y = "Number of AE events",
       title = "AE count by SOC and treatment") +
  theme_minimal(base_size = 12) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

ggsave("fig31_ae_soc.png", p, width = 7, height = 4.2, dpi = 150)
cat("png written:", file.exists("fig31_ae_soc.png"), "\n")
print(table(ae$AEBODSYS, ae$TRT01P))
cat("subjects with >=1 AE:", length(unique(ae$USUBJID)), "of", n, "\n")
# 实跑捕获(R 4.5.0,已去空行)
png written: TRUE
                   Placebo Drug 20mg Drug 50mg
  Gastrointestinal      17        19        16
  General               19        14        20
  Nervous system        15        19        17
  Respiratory           11         6         3
  Skin                  12        12        14
subjects with >=1 AE: 102 of 120
逐行解读:
  1. ggplot(ae, aes(...)):data 层指定"画哪个数据框",mapping 层(aes())指定"哪个变量走哪个视觉通道"——x=AEBODSYS 走横轴,fill=TRT01P 走填充色。SAS 里这两件事分别对应 DATA= 与 VBAR AEBODSYS / GROUP=TRT01P。
  2. geom_bar(position = "dodge"):geom 层决定"用什么几何体、怎么摆"。geom_bar() 默认 stat="count",也就是自己数行数,你不需要先跑一遍频数;position="dodge" 让同一 SOC 的三个治疗组并排(SAS 里是 GROUPDISPLAY=CLUSTER)。想堆叠就换 position="stack",想看比例就换 position="fill"。
  3. labs():轴标题、图标题、图例标题。对应 SAS 的 LABEL= / XAXISLABEL=,但 R 里"标签"是图层的一部分,可以被后面的图层覆盖。
  4. theme_minimal(base_size = 12):theme 层只管长相(网格线、字号、背景),不管数据。类比 ODS 图形样式(ODS GRAPHICS / RESET=ATTRVAR 或 STYLEATTRS)。
  5. theme(axis.text.x = element_text(angle = 45, hjust = 1)):theme 是"局部覆盖全局"的——先套一个整体主题,再单独把 x 轴标签转 45°。hjust=1 让标签右对齐到刻度上,这是 SOC 这类长标签的标准处理。
  6. ggsave():把图落盘。递交里请用 ggsave,不要用截图:它显式记录 width/height/dpi,图形尺寸可复现、可写进程序头注释。
  7. table(ae$AEBODSYS, ae$TRT01P):这就是图上每根柱子的高度,也是你 QC 时的"数字底稿"。图形与表格永远一起核对。
ggplot2 的层职责SAS PROC SGPLOT 里的近似对应
data用哪个数据集DATA=
mapping(aes)变量 → 视觉通道(x/y/fill/color/size/shape)VBAR x / RESPONSE=y / GROUP=g
geom几何体与统计变换(bar/boxplot/point/step…)选择 PROC 内的语句(VBAR、VBOX、SCATTER、STEP)
scale坐标轴/颜色/图例的取值与变换(log 轴、自定义色)XAXIS TYPE=LOG、STYLEATTRS DATACONTRASTCOLORS=
facet按变量拆成多面板(小倍数图)BY 语句 / PANELBY
theme纯外观:字号、网格、边距ODS 图形样式、ODS GRAPHICS / IMAGEMAP

再叠两层看看威力——scale 自定义颜色与坐标轴余量,facet 按治疗组拆面板。同样的数据,换两层就换了一种表达:

## scale 层:自定义填充色 + 去掉 y 轴下方留白;facet 层:按治疗组拆面板
adsl$SEX <- factor(sample(c("F", "M"), n, replace = TRUE))
ae <- merge(ae, adsl[, c("USUBJID", "SEX")], by = "USUBJID")
p2 <- ggplot(ae, aes(x = AEBODSYS, fill = SEX)) +
  geom_bar(position = "dodge") +
  facet_wrap(~ TRT01P) +
  scale_fill_manual(values = c(F = "#8172B3", M = "#64B5CD"), name = "Sex") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(x = "System Organ Class", y = "Number of AE events") +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggsave("fig31b_ae_facet.png", p2, width = 9, height = 3.6, dpi = 150)
cat("facet png written:", file.exists("fig31b_ae_facet.png"), "\n")
print(table(ae$TRT01P, ae$SEX))
# 实跑捕获(R 4.5.0,已去空行)
facet png written: TRUE
             F  M
  Placebo   32 42
  Drug 20mg 38 32
  Drug 50mg 31 39
SAS 误区:在 SAS 里你会先 PROC FREQ 出计数再画,或者用 VBAR SOC / RESPONSE=COUNT STAT=SUM;geom_bar() 默认自己数行数。ADAEM 是"一行一条 AE",所以数出来的是 AE 条数(本例 214 条),不是受试者数。要画"有 ≥1 次 AE 的受试者比例",必须先 unique(ae[, c("USUBJID","AEBODSYS","TRT01P")]) 去重再画,或用 geom_bar(aes(y = USUBJID), stat = "unique")(ggplot2 3.4+)。图注写"n (%) of subjects"却画的是事件数,是 AE 图最常见的翻车点。

测验 1:aes(x = AEBODSYS, fill = TRT01P) 这一句在图层语法里扮演什么角色?

选 B。数据框由 ggplot(ae, ...) 的第一个参数决定;颜色的具体色值由 scale 层(如 scale_fill_manual())决定;aes 只负责"哪个变量映射到哪个通道"。分清 mapping 与 scale,是读懂任何 ggplot 代码的钥匙。

3.2 Surv 对象:生存数据在 R 里的最小结构

SAS 的 PROC LIFETEST 吃两个变量:TIME= 和 STATUS=。R 的 survival 包把这两列打包成一个特殊矩阵——Surv 对象,删失的行在打印时带 + 号。

library(survival)
lung <- survival::lung
## Surv(时间, 事件指示) -> 一个 228 x 2 的特殊矩阵
s <- Surv(lung$time, lung$status == 2)
cat("class:", class(s), "| type:", attr(s, "type"),
    "| dim:", paste(dim(s), collapse = " x "), "\n")
print(head(s, 10))
cat("--- event flag column, first 10 ---\n")
print(head(s[, "status"], 10))
cat("--- original status coding in lung ---\n")
print(table(lung$status))
cat("--- recoded event counts (1 = death, 0 = censored) ---\n")
print(table(s[, "status"]))
# 实跑捕获(R 4.5.0,已去空行)
class: Surv | type: right | dim: 228 x 2
 [1]  306   455  1010+  210   883  1022+  310   361   218   166
--- event flag column, first 10 ---
 [1] 1 1 0 1 1 0 1 1 1 1
--- original status coding in lung ---
  1   2
 63 165
--- recoded event counts (1 = death, 0 = censored) ---
  0   1
 63 165
逐行解读:
  1. Surv(time, event) 返回的是 class = "Surv" 的 228 × 2 矩阵:第 1 列是时间,第 2 列(列名 status)是事件指示。attr(s, "type") = "right" 表示右删失——正是 LIFETEST 的默认设定。
  2. print(head(s, 10)) 里的 1010+、1022+:加号就是删失。这一眼扫过去,你能立刻分辨"到 1010 天还活着(随访截尾)"和"到 1010 天发生事件"。这是 SAS 输出里没有的直观表达。
  3. lung$status 的原始编码是 1 = 删失、2 = 死亡(反直觉!),所以 lung$status == 2 生成 TRUE/FALSE,Surv 自动转成 1 = 事件、0 = 删失,共 165 个事件、63 个删失。
  4. 事件编码的铁律:R 里 1 = 发生事件,0 = 删失。与 SAS 不同——LIFETEST 里 STATUS= 的非缺失值默认全算事件,你得用 STATUS=xx(event) 或 EVENTS= 显式指定哪些值算事件。
对照 PROC LIFETEST DATA=adtte TIME=ADT/AVAL EVENT=AVAL(CNSR):SAS 用"哪个值算事件"的括号语法,R 用"逻辑表达式直接生成 0/1"。两种写法都要求你在动手前把 ADTTE 的 CNSR / EVNTDESC 编码查清楚——这一步在哪个语言里都省不掉。
SAS 误区:直接写 Surv(time, status) 把原始编码扔进去。survival 包有个"贴心"规则:当数值型 event 的最大值恰好为 2 时,它自动执行 status - 1——所以 Surv(lung$time, lung$status) 侥幸得到正确结果(165 个事件)。但一旦你的编码是三态(0=存活、1=进展、2=死亡),减 1 后会出现 -1,Surv 只抛一条警告 Invalid status value, converted to NA,然后静默删行。我们实跑验证过:9 条记录里 3 条变成 NA,survfit 只报一句 "3 observations deleted due to missingness",样本量悄悄少了三分之一而分析照跑。递交代码里请永远显式写 as.integer(status == 2) 或 factor(status)。

测验 2:ADTTE 里 CNSR 编码为 0=事件、1=删失,下面哪句是正确的 Surv 写法?

选 B。Surv(AVAL, CNSR) 会把"删失"(CNSR=1)当成事件、把"事件"(CNSR=0)当成删失,方向完全反了,而且不会报任何错——KM 曲线照样画出来。正确写法是 Surv(AVAL, CNSR == 0),或更保险地 Surv(AVAL, as.integer(CNSR == 0))。C 是陷阱:CNSR 最大值为 1,不触发"最大值等于 2 就减 1"的规则,所以没有任何自动纠正。

3.3 KM 曲线与 log-rank 检验

三件套:survfit() 出 KM 估计与中位生存时间、survdiff() 出 log-rank 检验、survminer::ggsurvplot() 出可直接进报告的图。数据仍用 lung,按性别分组。

library(survival)
lung <- survival::lung
lung$event <- as.integer(lung$status == 2)   # 1 = 死亡,0 = 删失
fit <- survfit(Surv(time, event) ~ sex, data = lung)
print(fit)                                   # n / events / median / 95% CI
cat("--- survival rate at 365 days ---\n")
print(round(summary(fit, times = 365)$surv, 3))
cat("--- log-rank test ---\n")
print(survdiff(Surv(time, event) ~ sex, data = lung))
cat("--- survminer plot ---\n")
library(survminer)
p <- ggsurvplot(fit, data = lung, pval = TRUE, conf.int = TRUE,
                risk.table = TRUE, xlab = "Days", ylab = "Survival probability",
                legend.labs = c("Male", "Female"),
                palette = c("#4C72B0", "#DD8452"), ggtheme = theme_minimal())
ggsave("fig33_km_sex.png", plot = p$plot, width = 7, height = 4.6, dpi = 150)
cat("png written:", file.exists("fig33_km_sex.png"), "\n")
# 实跑捕获(R 4.5.0,已去空行)
Call: survfit(formula = Surv(time, event) ~ sex, data = lung)
        n events median 0.95LCL 0.95UCL
sex=1 138    112    270     212     310
sex=2  90     53    426     348     550
--- survival rate at 365 days ---
[1] 0.336 0.526
--- log-rank test ---
Call:
survdiff(formula = Surv(time, event) ~ sex, data = lung)
        N Observed Expected (O-E)^2/E (O-E)^2/V
sex=1 138      112     91.6      4.55      10.3
sex=2  90       53     73.4      5.68      10.3
 Chisq= 10.3  on 1 degrees of freedom, p= 0.001
--- survminer plot ---
png written: TRUE
逐行解读:
  1. survfit(Surv(time, event) ~ sex, data = lung):公式右边是分组变量,等价于 SAS 的 STRATA sex;(注意:不是 class,是分层出多条 KM 曲线)。
  2. print(fit) 就是 LIFETEST 输出里最常被引用的那几行:男性(sex=1)138 例、112 个事件、中位生存 270 天(95% CI 212–310);女性 90 例、53 个事件、中位 426 天(348–550)。这个表可以直接进 TLF 的"生存总结"部分。
  3. summary(fit, times = 365)$surv:取指定时间点的生存率——365 天时男性 0.336、女性 0.526。这就是"1 年生存率",SAS 里对应 TIMELIST=(365) 或 SURVIVALPLOT 上的读数。要 CI 就取 summary(fit, times=365)$lower / $upper。
  4. survdiff():log-rank 检验。Chisq = 10.3、df = 1、p = 0.001,与 LIFETEST 的 "Test of Equality over Strata"(Log-Rank 那一行)逐字对应。表里的 Observed / Expected / (O-E)^2/V 三列也和 SAS 一致。
  5. ggsurvplot() 参数:pval=TRUE 在图上打 log-rank p 值,conf.int=TRUE 画 95% 置信带,risk.table=TRUE 在曲线下方加"风险人数表"(递交图几乎必备),legend.labs 把 sex=1/2 翻译成人话。
  6. ggsave(plot = p$plot, ...):ggsurvplot 返回的是一个列表(曲线 + 风险表 + p 值注记),不是单个 ggplot 对象,所以要指定 p$plot。想把曲线和风险表一起存盘,用 png(); print(p); dev.off()。
两个实用变体:
① 不想先造 event 列,可以直接把逻辑表达式写进公式:survfit(Surv(time, status == 2) ~ sex, data = lung)——我们实跑验证过,两条曲线与中位数完全一致(identical(fit1$surv, fit2$surv) == TRUE)。但递交程序里更推荐显式造列,因为 ADaM 变量应当可追溯到 spec。
② survdiff(..., rho = 1) 是 Gehan-Breslow-Wilcoxon 检验(对应 LIFETEST 的 /WILCOX);默认 rho = 0 才是 log-rank。SAP 写的是哪个,就用哪个。
SAS 误区:中位生存时间的 CI 对不上。survfit 默认 conf.type = "log"(对 log(−log S) 做正态近似再回变换),而 LIFETEST 的 CLTYPE= 还有 LINEAR、LOGIT、ARC_SINE 等选项。双编程比对时中位数差 1–2 天,先查这两个选项,再怀疑数据。

3.4 Cox 比例风险模型

coxph() 就是 PROC PHREG。模型:Surv(time, event) ~ age + sex + ph.ecog。

library(survival)
lung <- survival::lung
lung$event <- as.integer(lung$status == 2)
d <- lung[, c("time", "event", "age", "sex", "ph.ecog")]
cat("rows total:", nrow(d), "| after dropping NA:", nrow(na.omit(d)), "\n")
d <- na.omit(d)
fit <- coxph(Surv(time, event) ~ age + sex + ph.ecog, data = d)
s <- summary(fit)
cat("ties method:", fit$method, "\n")
print(round(s$coefficients, 4))
cat("--- hazard ratios (exp(coef)) with 95% CI ---\n")
print(round(s$conf.int, 3))
cat("concordance:", round(s$concordance[1], 3),
    "| likelihood ratio p =", signif(s$logtest[3], 3), "\n")
cat("n =", s$n, "| events =", s$nevent, "\n")
# 实跑捕获(R 4.5.0)
rows total: 228 | after dropping NA: 227
ties method: efron
           coef exp(coef) se(coef)       z Pr(>|z|)
age      0.0111    1.0111   0.0093  1.1942   0.2324
sex     -0.5526    0.5754   0.1677 -3.2945   0.0010
ph.ecog  0.4637    1.5900   0.1136  4.0829   0.0000
--- hazard ratios (exp(coef)) with 95% CI ---
        exp(coef) exp(-coef) lower .95 upper .95
age         1.011      0.989     0.993     1.030
sex         0.575      1.738     0.414     0.799
ph.ecog     1.590      0.629     1.273     1.986
concordance: 0.637 | likelihood ratio p = 1.08e-06
n = 227 | events = 164
逐行解读:
  1. ties method: efron:处理同一时间多个事件(ties)的方法。coxph 默认 Efron,PROC PHREG 的 TIES= 默认也是 EFRON——这一点两边天然对齐,不用特意设置。要复现老程序的 TIES=BRESLOW,写 coxph(..., ties = "breslow")。
  2. coef 是对数风险比;exp(coef) 才是 HR,也就是 PHREG 输出里的 "Hazard Ratio" 列。se(coef)、z、Pr(>|z|) 三列与 PHREG 的 "Pr > |z|" 完全同义(都是 Wald 检验)。
  3. 怎么读这三个 HR:age HR = 1.011(95% CI 0.993–1.030,p = 0.23)——每大 1 岁风险约高 1.1%,但不显著;ph.ecog HR = 1.590(1.273–1.986,p < 0.001)——ECOG 每恶化 1 级,死亡风险约升 59%,是最强预后因子;sex HR = 0.575(0.414–0.799,p = 0.001)。
  4. concordance = 0.637:C 指数(判别力),相当于把所有可比较的病对里"模型排序正确"的比例,0.5 = 瞎猜、1 = 完美。SAS 里要额外算(%concordance 宏或 PHREG 无直接输出),R 里 summary() 白送。
  5. n = 227, events = 164:分析集与事件数——写进表格脚注的两个数。原始 228 行里有 1 行 ph.ecog 缺失被删掉。
SAS 误区一(静默删行):coxph 默认 na.action = na.omit,缺一个协变量就整行扔掉,只在 print(fit) 里留一句 "1 observation deleted"。SAS 的 PROC PHREG 也是默默删,但至少 log 里有个数。递交程序里请先 na.omit() 并打印行数(本例代码就是这么做的:228 → 227),或者在 SAP 里写清"完整病例分析"。
SAS 误区二(把数值当分类):SAS 的 CLASS 语句会自动把分类变量做成哑变量;coxph 对 factor 也这么做,但对数值变量一律当连续线性。lung$sex 是 1=男、2=女,模型把它当成连续变量,于是 HR = 0.575 的字面含义是"sex 每增加 1 个单位(男 → 女)"。结果数值上没错(两水平时等价),但表述荒谬、且三水平以上就彻底错了。规范写法:factor(sex, levels = c(1, 2), labels = c("Male","Female")) 并显式设参考组(relevel())。

测验 3:本模型中 sex(1=男,2=女)的 exp(coef) = 0.575,正确解读是?

选 B。HR 小于 1 表示"比较组"风险更低,而这里的比较组是数值更大的那一组(女性)。这与 3.3 的 KM 结果一致:女性中位生存 426 天 > 男性 270 天。A 把方向说反了;C 是把分类变量误当连续百分比解读。

3.5 MMRM:mmrm 包与 PROC MIXED 的对照

MMRM(混合效应重复测量模型)是纵向疗效分析的主力,R 里的对应物是 mmrm 包——它不是 lme4 的包装,而是基于 TMB 的专用实现,默认就给你 Satterthwaite(或 Kenward-Roger)自由度调整,专为"每个访视点的治疗差"这一估计目标设计。

先造数据:80 例受试者 × 3 个访视(Week 4/8/12),变量 USUBJID / ARM / BASE / AVISIT / AVISITN / FEV1,并在 Week 12 制造 14 条 MAR 式脱落。

set.seed(42)
nsub <- 80
sub <- data.frame(
  USUBJID = sprintf("S%03d", seq_len(nsub)),
  ARM     = factor(rep(c("Placebo", "Active"), length.out = nsub),
                   levels = c("Placebo", "Active")),
  BASE    = round(rnorm(nsub, mean = 50, sd = 10), 1)
)
sub$bi <- round(rnorm(nsub, 0, 4), 2)   # 受试者随机截距:纵向相关性的来源
dat <- data.frame(
  USUBJID = rep(sub$USUBJID, each = 3),
  ARM     = factor(rep(sub$ARM, each = 3), levels = levels(sub$ARM)),
  BASE    = rep(sub$BASE, each = 3),
  bi      = rep(sub$bi, each = 3),
  AVISITN = rep(1:3, times = nsub),
  AVISIT  = factor(rep(c("Week 4", "Week 8", "Week 12"), times = nsub),
                   levels = c("Week 4", "Week 8", "Week 12"))
)
dat$FEV1 <- round(dat$BASE + 3 * dat$AVISITN +
                  ifelse(dat$ARM == "Active", 2.5 * dat$AVISITN, 0) +
                  dat$bi + rnorm(nrow(dat), 0, 3), 1)
## MAR 式脱落:随机删掉 14 条 Week 12 记录
dropout <- sample(which(dat$AVISITN == 3), 14)
dat <- dat[!seq_len(nrow(dat)) %in% dropout,
           c("USUBJID", "ARM", "BASE", "AVISITN", "AVISIT", "FEV1")]
cat("rows:", nrow(dat), "| subjects:", length(unique(dat$USUBJID)), "\n")
print(table(dat$ARM, dat$AVISIT))
cat(paste(capture.output(head(dat, 4)), collapse = "\n"), "\n")
# 实跑捕获(R 4.5.0,已去空行)
rows: 226 | subjects: 80
          Week 4 Week 8 Week 12
  Placebo     40     40      35
  Active      40     40      31
  USUBJID     ARM BASE AVISITN  AVISIT FEV1
1    S001 Placebo 63.7       1  Week 4 72.2
2    S001 Placebo 63.7       2  Week 8 72.5
3    S001 Placebo 63.7       3 Week 12 79.2
4    S002  Active 44.4       1  Week 4 49.8
逐行解读:
  1. 数据是长格式(一人一访视一行),240 行减去 14 条脱落 = 226 行、80 个受试者。这正是 PROC MIXED 需要的形状,也是 ADaM ADLB/ADVSS 的形状。
  2. 真实效应设定为:Active 组每个访视额外增加 2.5 × AVISITN,即 Week 4 差 2.5、Week 8 差 5.0、Week 12 差 7.5。后面看到估计值 8.8 时,你知道那是抽样波动,不是模型错。
  3. sub$bi 是受试者级随机截距(SD = 4),它让同一个人的三次测量彼此相关——这就是"重复测量"要建模的东西。它不出现在最终数据里,只作为数据生成机制存在。
  4. AVISIT 必须是 factor 且 levels 按访视顺序排列:mmrm 用它的水平来定义协方差矩阵的行列,顺序错了矩阵就错了。
library(mmrm)
fit <- mmrm(FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE + us(AVISIT | USUBJID),
            data = dat)
print(fit)
# 实跑捕获(R 4.5.0,已去空行)
mmrm fit
Formula:     FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE + us(AVISIT | USUBJID)
Data:        dat (used 226 observations from 80 subjects with maximum 3
timepoints)
Covariance:  unstructured (6 variance parameters)
Inference:   REML
Deviance:    1252.894
Coefficients:
            (Intercept)               ARMActive            AVISITWeek 8
              3.6621742               3.0170305               2.8625000
          AVISITWeek 12                    BASE  ARMActive:AVISITWeek 8
              5.6948399               0.9731705               2.9075000
ARMActive:AVISITWeek 12
              5.7788999
Model Inference Optimization:
Converged with code 0 and message: convergence: rel_reduction_of_f <= factr*epsmch
逐行解读:
  1. 公式左边 FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE 是固定效应:治疗主效应、访视主效应、治疗 × 访视交互、基线校正。SAP 里那句"MMRM with treatment, visit, treatment-by-visit interaction and baseline as covariates"就是它。
  2. us(AVISIT | USUBJID) 是随机效应/协方差部分:us() = unstructured 非结构协方差,AVISIT = 在哪个维度上建协方差(访视),USUBJID = 谁是重复测量的个体。整体对应 SAS 的 REPEATED AVISIT / SUBJECT=USUBJID TYPE=UN;。
  3. Covariance: unstructured (6 variance parameters):3 个访视的非结构矩阵 = 3 个方差 + 3 个协方差 = 6 个参数(n(n+1)/2)。它不假设相关随时间衰减,因此最"贵"也最保守——这是监管场景的默认选择。
  4. Inference: REML:与 PROC MIXED 默认一致。注意:REML 的似然不能用于比较固定效应不同的模型,要比 AIC 需 reml = FALSE(3.6 节和本章练习会用到)。
  5. Converged with code 0:收敛信息。SAS 里你会去 log 里找 "Convergence criteria met";R 里 print(fit) 直接印出来,fit$opt_details 里还有更多细节。没确认收敛就不要往下读结果。
  6. 系数里 BASE = 0.973 接近 1,说明基线校正工作正常;ARMActive = 3.017 是参考访视(Week 4)的治疗差,不是总体差。

模型跑通只是第一步,你要的三个东西是:协方差矩阵、治疗差的 CI、指定访视点的校正均数差。

cat("--- residual within-subject covariance (US, 3 visits) ---\n")
print(round(VarCorr(fit), 2))
cat("--- treatment-related coefficients with 95% CI ---\n")
tab <- cbind(Estimate = coef(fit), confint(fit))
print(round(tab[grep("ARM", rownames(tab)), , drop = FALSE], 3))
cat("--- adjusted difference at Week 12 via emmeans ---\n")
library(emmeans); emm_options(lmer.df = "satterthwaite")
em <- emmeans(fit, pairwise ~ ARM | AVISIT)
print(subset(em$contrasts, AVISIT == "Week 12"))
cat("--- information criteria ---\n")
cat("logLik:", round(as.numeric(logLik(fit)), 2), "| AIC:", round(AIC(fit), 2),
    "| BIC:", round(BIC(fit), 2), "\n")
# 实跑捕获(R 4.5.0,已去空行)
--- residual within-subject covariance (US, 3 visits) ---
        Week 4 Week 8 Week 12
Week 4   25.06  12.13   16.54
Week 8   12.13  18.71   14.15
Week 12  16.54  14.15   24.77
--- treatment-related coefficients with 95% CI ---
                        Estimate 2.5 % 97.5 %
ARMActive                  3.017 0.788  5.246
ARMActive:AVISITWeek 8     2.908 0.941  4.874
ARMActive:AVISITWeek 12    5.779 3.829  7.729
--- adjusted difference at Week 12 via emmeans ---
 contrast         AVISIT  estimate   SE   df t.ratio p.value
 Placebo - Active Week 12     -8.8 1.16 75.5  -7.551 <0.0001
--- information criteria ---
logLik: -626.45 | AIC: 1264.89 | BIC: 1279.19
逐行解读:
  1. VarCorr(fit) 给出受试者内残差协方差矩阵(3×3,对称):对角线是各访视的残差方差(25.06 / 18.71 / 24.77),非对角是协方差。对应 SAS 的 ESTIMATE 后 "Covariance Parameter Estimates" 里 UN 的 6 个参数(SAS 印的是 UN(1,1)…UN(3,3) 与 UN(2,1)…UN(3,2),同一批数、不同排版)。
  2. confint(fit) 默认用 Satterthwaite 自由度,等价于 PROC MIXED 的 DDFM=SATTERTHWAITE。想要 Kenward-Roger 就 confint(fit, method = "KR")(对应 DDFM=KR)。
  3. 关键陷阱:ARMActive:AVISITWeek 12 = 5.779 不是 Week 12 的治疗差!它只是"相对 Week 4 的增量差"。真正的 Week 12 校正差 = ARMActive + ARMActive:AVISITWeek 12 = 3.017 + 5.779 ≈ 8.8。SAS 里 LSMEANS ARM*AVISIT / DIFF; 会自动帮你组合,R 里必须自己写线性组合或用 emmeans。
  4. emmeans(fit, pairwise ~ ARM | AVISIT) 就是 LSMEANS / DIFF 的 R 版:输出 Week 12 的差 −8.8(SE 1.16,df 75.5,t = −7.55,p < 0.0001)。符号为负是因为 emmeans 按字母序做了 Placebo − Active;翻正号即 Active 比 Placebo 高 8.8。这与手工线性组合的结果逐位一致,可互为 QC。
  5. df = 75.5 是小数——这正是 Satterthwaite 调整的特征(PROC MIXED 也会给 75.x 这种小数自由度)。看到整数 df 反而要警惕是不是退化成 z 检验了。
SAS PROC MIXEDR mmrm说明
MODEL FEV1 = ARM AVISIT ARM*AVISIT BASE / DDFM=SATTHER;FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE固定效应;mmrm 默认即 Satterthwaite
REPEATED AVISIT / SUBJECT=USUBJID TYPE=UN;+ us(AVISIT | USUBJID)非结构协方差;TYPE=CS/ARH/TOEPH 对应 cs()/ar1h()/toeph()
METHOD=REML(默认)reml = TRUE(默认)比较 AIC 时两边都要换成 ML
LSMEANS ARM*AVISIT / DIFF CL;emmeans(fit, pairwise ~ ARM | AVISIT)校正均数与治疗差
ESTIMATE 'diff at W12' ...;手工线性组合 L %*% coef(fit),或 emmeans contrast()自定义估计目标
Covariance Parameter EstimatesVarCorr(fit)协方差参数
SAS 误区(本章作者亲自踩过):网上大量教程与旧版文档写 mmrm(FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE | USUBJID, data = dat),即把 | USUBJID 直接接在固定效应后面。在 mmrm 0.3.18 上这句会直接报错:Covariance structure must be specified in formula. Possible covariance structures include: us, toep, toeph, ar1, ar1h, ad, adh, cs, csh, sp_exp, sp_gau。现在的语法要求把协方差结构、时间维度、个体三件事一起写进 us(AVISIT | USUBJID)。看到旧代码请照此改写;报错信息本身已经把可用结构列全了。

测验 4:mmrm 公式中 | USUBJID 的作用是什么?

选 B。竖线左边是协方差结构与时间维度(us(AVISIT)),右边是"谁被重复测量"。它对应 PROC MIXED 的 SUBJECT=USUBJID:模型据此知道哪几行属于同一个人,从而为这些行的残差估计一个协方差矩阵,而不是假设所有观测独立。选 A 会把 80 个受试者做成 80 个哑变量(固定个体效应),那不是 MMRM;选 C 完全无关。

3.6 lme4 对照:什么时候用 lmer,什么时候用 mmrm

同一份数据,用 lme4::lmer() 写"随机截距模型"。它更通用、生态更大,但假设更强。

library(lme4)
fit2 <- lmer(FEV1 ~ ARM * AVISIT + BASE + (1 | USUBJID), data = dat)
cat("--- variance components: random intercept => compound symmetry ---\n")
print(VarCorr(fit2))
cat("--- fixed effects ---\n")
print(round(summary(fit2)$coefficients[, 1:2], 3))
cat("REML deviance:", round(deviance(fit2, REML = TRUE), 2),
    "| logLik:", round(as.numeric(logLik(fit2)), 2), "\n")
library(mmrm)
fit1 <- mmrm(FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE + us(AVISIT | USUBJID), data = dat)
cat("mmrm US logLik:", round(as.numeric(logLik(fit1)), 2),
    "| mmrm US AIC:", round(AIC(fit1), 2), "\n")
cat("lmer RI AIC:", round(AIC(fit2), 2), "(ML vs REML: not directly comparable)\n")
fit2ml <- lmer(FEV1 ~ ARM * AVISIT + BASE + (1 | USUBJID), data = dat, REML = FALSE)
cat("lmer ML AIC:", round(AIC(fit2ml), 2),
    "| mmrm ML AIC:", round(AIC(update(fit1, reml = FALSE)), 2), "\n")
# 实跑捕获(R 4.5.0)
--- variance components: random intercept => compound symmetry ---
 Groups   Name        Std.Dev.
 USUBJID  (Intercept) 3.7517
 Residual             2.9443
--- fixed effects ---
                        Estimate Std. Error
(Intercept)                2.999      2.308
ARMActive                  3.017      1.066
AVISITWeek 8               2.862      0.658
AVISITWeek 12              5.700      0.690
BASE                       0.986      0.043
ARMActive:AVISITWeek 8     2.908      0.931
ARMActive:AVISITWeek 12    5.848      0.998
REML deviance: 1259.42 | logLik: -629.09
mmrm US logLik: -626.45 | mmrm US AIC: 1264.89
lmer RI AIC: 1276.19 (ML vs REML: not directly comparable)
lmer ML AIC: 1277.42 | mmrm ML AIC: 1265.9
逐行解读:
  1. (1 | USUBJID):竖线在这里的含义与 mmrm 不同——左边是随机效应项(截距 1),右边是分组变量。整句意思是"每个受试者有一个自己的随机截距"。
  2. 随机截距 ⇒ 协方差矩阵形如 σ²_b·J + σ²·I,即复合对称(CS):所有访视方差相同、任意两次测量相关相同。这里 σ_b = 3.75、σ = 2.94,隐含相关 ≈ 3.75²/(3.75²+2.94²) ≈ 0.62。只有 2 个协方差参数,而 mmrm 的 US 用了 6 个。
  3. 固定效应点估计与 mmrm 几乎相同(ARMActive 都是 3.017,BASE 0.986 vs 0.973),但标准误不同(1.066 vs mmrm 的 Satterthwaite SE),且 lmer 的 summary() 默认不给 p 值——这是 lme4 作者的刻意设计(分母自由度无公认定义)。要 p 值请装 lmerTest,它把 lmer 对象升级成带 Satterthwaite/KR 自由度的版本。
  4. AIC 比较必须同为 ML:ML 下 mmrm(US) 1265.90 优于 lmer(CS) 1277.42,说明这份数据的残差协方差确实不是复合对称的(US 用更多参数换到了更好的拟合)。上面 lmer RI AIC: 1276.19 是 REML 值,不能与 ML 值直接比,所以代码里特地又跑了一次 REML = FALSE。
需求选 mmrm选 lme4::lmer
监管递交的主疗效分析(每个访视点的校正差 + CI)✓ 默认 Satterthwaite/KR、与 PROC MIXED REPEATED 一一对应、emmeans 原生支持需 lmerTest 补 p 值,协方差结构受限
非结构 / Toeplitz / AR(1) 等残差协方差✓ us/toeph/ar1/cs 等开箱即用需手写结构或用 nlme/gls
多层随机效应(中心 + 受试者)、GLMM、非正态结局、大样本不适合✓ 这是 lme4 的主场
随机斜率、生长曲线、缺失机制建模有限✓ (AVISITN | USUBJID) 一句搞定
一句话记法:纵向疗效主分析用 mmrm(它是为 SAS PROC MIXED REPEATED 的替代而生的);复杂随机效应结构与非正态结局用 lme4。两者竖线语法形似而义不同,这是 SAS 转 R 最容易混的一处。

3.7 分类数据:卡方与 CMH 分层分析

响应率比较:先粗分析(2×2 卡方),再按中心分层(Mantel-Haenszel)。下面四个中心的合计正好等于粗表,这样你能直接看出分层有没有改变结论。

tab <- matrix(c(42, 18, 30, 35), nrow = 2, byrow = TRUE,
              dimnames = list(Treatment = c("Active", "Placebo"),
                              Response  = c("Yes", "No")))
print(tab)
print(chisq.test(tab))
cat("crude OR:", round((42 * 35) / (18 * 30), 3), "\n")
# 实跑捕获(R 4.5.0,已去空行)
         Response
Treatment Yes No
  Active   42 18
  Placebo  30 35
	Pearson's Chi-squared test with Yates' continuity correction
data:  tab
X-squared = 6.3209, df = 1, p-value = 0.01193
crude OR: 2.722
## 2 x 2 x K 数组:第三维是分层变量(中心)
cen <- array(dim = c(2, 2, 4),
             dimnames = list(Treatment = c("Active", "Placebo"),
                             Response  = c("Yes", "No"),
                             Center    = paste("Site", 1:4)))
vals <- list(c(12, 4, 9, 11), c(9, 3, 8, 10), c(11, 6, 7, 6), c(10, 5, 6, 8))
for (k in 1:4) cen[, , k] <- matrix(vals[[k]], 2, byrow = TRUE)
cat("--- site margins sum back to the crude table ---\n")
print(apply(cen, c(1, 2), sum))
print(mantelhaen.test(cen))
# 实跑捕获(R 4.5.0,已去空行)
--- site margins sum back to the crude table ---
         Response
Treatment Yes No
  Active   42 18
  Placebo  30 35
	Mantel-Haenszel chi-squared test with continuity correction
data:  cen
Mantel-Haenszel X-squared = 6.1387, df = 1, p-value = 0.01323
alternative hypothesis: true common odds ratio is not equal to 1
95 percent confidence interval:
 1.305296 5.774277
sample estimates:
common odds ratio
         2.745385
逐行解读:
  1. chisq.test(tab):Pearson 卡方,X-squared = 6.3209、df = 1、p = 0.0119。粗 OR = (42×35)/(18×30) = 2.722,即 Active 组响应优势约为 Placebo 的 2.7 倍。
  2. 注意输出标题里的 "with Yates' continuity correction":R 对 2×2 表默认做耶茨连续性校正,PROC FREQ 的 /CHISQ 不校正。要复现 SAS 的 7.2645 / p = 0.00703,必须写 chisq.test(tab, correct = FALSE)(两个数都实跑验证过)。这是双编程比对里"p 值总差一点"的头号原因。
  3. mantelhaen.test(cen):输入是 2 × 2 × K 数组,第三维就是 SAS STRATA 语句里的分层变量。输出三件东西:CMH 卡方(6.1387,p = 0.0132)、合并 OR 及其 95% CI(2.745,1.305–5.774)、以及备择假设说明。
  4. 粗 OR 2.722 与分层 OR 2.745 几乎相同 ⇒ 中心不是明显的混杂因素,也没有强烈的效应异质性。这正是 CMH 的用途:在控制分层变量的同时给出一个合并估计。若两者差异很大,就要回头查中心效应(并考虑 Breslow-Day 同质性检验,mantelhaen.test 不提供,需用 DescTools::BreslowDayTest())。
  5. 对应 SAS:PROC FREQ; TABLES TRT*RESP / CHISQ CMH RELRISK; STRATA CENTER; —— /CMH 输出的 "Mantel-Haenszel Chi-Square" 与 "Common Odds Ratio" 就是这里两行。
SAS 误区:① 忘了 correct = FALSE,卡方值与 SAS 对不上就怀疑数据;② 小样本硬用卡方——期望频数 < 5 时应改用 fisher.test(tab)(本例 Fisher p = 0.0109,OR = 2.70,与卡方结论一致),PROC FREQ 对应 /EXACT 或 FISHER 选项;③ 想复现 SAS 的 "Exact" 或 simulate.p.value 时,R 侧有 chisq.test(..., simulate.p.value = TRUE, B = 2000),但那是蒙特卡洛近似,与 SAS 的精确概率不是一回事。

测验 5:双编程时你在 R 里得到卡方 6.32、SAS 里得到 7.26,最可能的原因是?

选 A。加 correct = FALSE 后 R 给出 X-squared = 7.2645、p = 0.00703,与 SAS 一致。Pearson 卡方公式两边完全相同(C 错);数据没变(B 错)。同理,mantelhaen.test 默认也带连续性校正,要与 SAS CMH 严格对齐需评估这一点。

3.8 森林图:forestploter 画出亚组 HR

森林图的数据框要有一个留白列(这里名字就是一个空格 ` `),图形会画在那一列的位置上——这是 forestploter 的核心机制。

library(forestploter)
library(grid)
library(ggplot2)
df <- data.frame(
  Subgroup = c("Overall", "  Age < 65", "  Age >= 65", "  Male", "  Female"),
  N        = c(420, 250, 170, 240, 180),
  `HR (95% CI)` = c("0.72 (0.55-0.94)", "0.65 (0.46-0.92)", "0.84 (0.55-1.28)",
                    "0.70 (0.50-0.98)", "0.76 (0.48-1.20)"),
  ` `      = rep("", 5),
  check.names = FALSE, stringsAsFactors = FALSE)
est  <- c(0.72, 0.65, 0.84, 0.70, 0.76)
low  <- c(0.55, 0.46, 0.55, 0.50, 0.48)
high <- c(0.94, 0.92, 1.28, 0.98, 1.20)
tm <- forest_theme(base_size = 10, refline_gp = gpar(col = "grey40"),
                   ci_Theight = 0.01, legend.position = "bottom")
p <- forest(df, est = est, lower = low, upper = high,
            ci_column = 4, ref_line = 1, xlim = c(0.4, 1.4), theme = tm)
ggsave("fig38_forest.png", p, width = 8, height = 3.7, dpi = 150)
cat("class(p):", paste(class(p), collapse = "/"), "| png written:",
    file.exists("fig38_forest.png"), "\n")
print(df[, c("Subgroup", "N", "HR (95% CI)")])
# 实跑捕获(R 4.5.0)
class(p): forestplot/gtable/gTree/grob/gDesc | png written: TRUE
     Subgroup   N      HR (95% CI)
1     Overall 420 0.72 (0.55-0.94)
2    Age < 65 250 0.65 (0.46-0.92)
3   Age >= 65 170 0.84 (0.55-1.28)
4        Male 240 0.70 (0.50-0.98)
5      Female 180 0.76 (0.48-1.20)
逐行解读:
  1. `HR (95% CI)` 与 ` `:反引号让 R 接受带空格/括号的列名。` `(一个空格)就是留给图形的空白列,check.names = FALSE 防止 R 把它改名成 X.。
  2. est / lower / upper 是数值向量,顺序与 df 的行一一对应;而 HR (95% CI) 那一列是给人看的字符串。图形由数值画,文字由字符串显示,两者要你自己保证一致(这就是 QC 检查点)。
  3. ci_column = 4:图形画在第 4 列(即那个空格列)。ref_line = 1:无效线在 1(HR/OR/RR 用 1;若是均数差则用 0)。xlim = c(0.4, 1.4):横轴范围,务必覆盖所有 CI 上下限,否则线会被截断。
  4. forest_theme():主题对象。注意用 refline_gp = gpar(...);旧参数 refline_col 已弃用,会打印警告(本例输出中已无该警告,因为我们改用了新参数)。
  5. class(p) 是 forestplot/gtable/gTree/grob/gDesc——它是 grid 图形对象,不是 ggplot 对象。好消息:ggsave() 能接受它(内部自动包装),实跑成功;也可以用传统写法 png("f.png", width=2400, height=1100, res=300); grid.newpage(); grid.draw(p); invisible(dev.off()),这条路我们也验证过可行。
  6. Subgroup 里 " Age < 65" 的前导空格是手工缩进:森林图的层级感(Overall 顶格、亚组缩进)就靠字符串排版实现。
SAS 误区:SAS 里森林图往往靠 PROC SGPANEL + 注释或第三方宏拼,很多人干脆在 Word 里手画。R 这边要记住两条:① 亚组森林图不是多重检验——图上五个 HR 各不相同,不能据此宣称"某亚组更有效";要下这个结论必须做治疗 × 亚组的交互检验(Cox 里加 trt*subgroup,看交互项 p 值),并把亚组分析在 SAP 里声明为探索性。② 横轴是对数尺度思维:0.5 与 2.0 到无效线 1 的距离相等。若你把 xlim 设成线性对称(如 0–3),图会误导读者。

3.9 期中分析与成组序贯边界:gsDesign

期中分析的数学核心是 α 消耗(alpha spending):你多看一次数据,就必须为这次"看"预付一部分 I 类错误预算。gsDesign 直接算出边界。

library(gsDesign)
## k = 2 次分析(1 次期中 + 1 次最终),test.type = 1 为单侧优效设计
## alpha/beta/timing 均为默认值,这里显式写出便于对照 SAP
d <- gsDesign(k = 2, test.type = 1, alpha = 0.025, beta = 0.1, timing = 1)
print(d)
# 实跑捕获(R 4.5.0,已去空行)
One-sided group sequential design with
90 % power and 2.5 % Type I Error.
           Sample
            Size
  Analysis Ratio*  Z   Nominal p Spend
         1  0.504 2.75    0.0030 0.003
         2  1.009 1.98    0.0238 0.022
     Total                       0.0250
++ alpha spending:
 Hwang-Shih-DeCani spending function with gamma = -4.
* Sample size ratio compared to fixed design with no interim
Boundary crossing probabilities and expected sample size
assume any cross stops the trial
Upper boundary (power or Type I Error)
          Analysis
   Theta      1      2 Total   E{N}
  0.0000 0.0030 0.0220 0.025 1.0072
  3.2415 0.3271 0.5729 0.900 0.8437
逐行解读:
  1. k = 2:总共 2 次分析(期中 + 最终);timing = 1 表示期中安排在信息量 50% 处(输出中 Analysis 1 的 Sample Size Ratio = 0.504)。
  2. Z 边界:期中 Z = 2.75(名义 p = 0.0030),最终 Z = 1.98(名义 p = 0.0238)。Spend 列是这次分析"花掉"的 α:0.003 + 0.022 = Total 0.0250,正好等于预算,一分不多。
  3. 这就是"看数据的代价":最终分析的临界 p 从固定设计的 0.025 收紧到 0.0238。你的 SAP 必须写明用的是这个边界,而不是 0.025。
  4. Sample Size Ratio = 1.009:为了换取一次期中分析的机会,最大信息量(事件数/样本量)只需比固定设计多 0.9%——期中分析其实很便宜。
  5. 下半部分的越界概率表:Theta = 0.0000 是原假设成立的情形,两次分析累计越上界概率 0.0030 + 0.0220 = 0.025,即 I 类错误被精确控制在 2.5%;Theta = 3.2415 是备择假设(对应 90% power)的情形,累计越界 0.3271 + 0.5729 = 0.900,即 power 达标。
  6. E{N}:期望样本量(相对最大值的比例)。原假设下 1.0072(几乎必然跑到最大),备择假设下 0.8437——若药物真有效,平均只用 84% 的样本量就能停止。这才是期中分析的真正收益。
cat("--- bounds on the Z scale ---\n")
print(round(d$upper$bound, 4))
cat("--- information fraction / nominal p ---\n")
print(round(cbind(timing = d$timing, z = d$upper$bound,
                  p_nominal = pnorm(d$upper$bound, lower.tail = FALSE)), 4))
cat("information at analysis 1 / 2:", round(d$n.I, 3), "\n")
# 实跑捕获(R 4.5.0)
--- bounds on the Z scale ---
[1] 2.7500 1.9811
--- information fraction / nominal p ---
     timing      z p_nominal
[1,]    0.5 2.7500    0.0030
[2,]    1.0 1.9811    0.0238
information at analysis 1 / 2: 0.504 1.009
关于多重性,这一章只说一句:α 消耗函数就是多重性的账本。它把总 α = 0.025 按预设规则分摊到各次分析(这里用 Hwang-Shih-DeCani,γ = −4,属于"保守型",前期少花),因此无论你看几次,整体 I 类错误仍是 0.025。反过来,临时起意多看一次数据而没有事前 spending 函数,账本就崩了——这也是为什么 SAP 里的期中计划必须先于数据锁定。多个终点、多个剂量组的多重性是另一套工具(如 graph 化的 α 传播,见 gsDesign 的 gsDesignGG 相关功能与 gMCP 包)。

测验 6:为什么最终分析的名义 p 值边界是 0.0238 而不是 0.025?

选 B。α 消耗 0.003(期中)+ 0.022(最终)= 0.025。A 混淆了单双侧:单侧 0.025 本身就是这个设计的总预算,与是否分期无关;固定设计的单侧 0.025 边界正是 Z = 1.96 / p = 0.025。C 无关:power 影响的是样本量(这里是固定设计的 1.009 倍),不是边界的分配。

章末资源

  • mmrm(CRAN 官方页) — 主参考;含全部协方差结构、Satterthwaite/KR、emmeans 集成与"从 SAS 迁移"的说明。读它的 vignette《introduction》与《covariance》 进阶
  • survminer 文档站 — ggsurvplot() 全部参数图解,配色、风险表、p 值标注一次讲清,最适合照着改自己的 KM 图 入门
  • flexsurv(CRAN 官方页) — 参数化生存模型(Weibull、piecewise-exponential、多状态),需要外推长期生存或做卫生经济学建模时的主力 进阶
  • gsDesign 官方站点 — 成组序贯设计、α 消耗函数、样本量反推与图形化边界;配套书稿《gsDesign Bookdown》 进阶
  • Frank Harrell, Regression Modeling Strategies(RMS) — Cox 模型、比例风险检验、预测模型验证的经典教材;配套 rms 包 参考
  • 本地精读:../mmrm —— MMRM 深入理解指南 — 从矩阵基础、协方差结构(UN/AR(1)/CS/Toeplitz)、MAR 假设到 REML 与 Kenward–Roger、ICH E9(R1) 估计目标与 SAS/R 双实现对照,正好补齐本章 3.5 的理论底座 参考
  • 本地精读:../lmm —— 线性混合模型实战笔记 — 随机截距/随机斜率、方差成分解读与交互实验室,是 3.6 节 lmer 部分的延伸阅读 参考

本章练习

  1. (易)用 lung 把 KM 按 ECOG 体能状态分层:造 event 列,取 ph.ecog %in% c(0, 1) 的子集(ph.ecog = 3 只有 1 例,画出来没有意义),做 factor 后 survfit() + survdiff()。参考结果(已实跑):ECOG 0 组 n=63、中位 394 天(348–574);ECOG 1 组 n=113、中位 306 天(268–429);log-rank Chisq = 3.5,p = 0.06。
  2. (中)在 3.4 的 Cox 模型里加交互项 sex * ph.ecog,用 anova(fit0, fit1) 做似然比检验,并解释"加了交互项后主效应系数的含义变了"。参考结果(已实跑):交互项 exp(coef) = 1.178、p = 0.51;LR 检验 Chisq = 0.436、Df = 1、p = 0.509 ⇒ 无证据支持交互。注意此时 sex 的系数(−0.730)已不再是"总体性别效应",而是 ph.ecog = 0 时的性别效应。
  3. (难)把 3.5 的 MMRM 换成不同协方差结构,用 ML(reml = FALSE)比较 AIC/BIC,并检查 Week 12 的校正差是否稳健:us() / cs() / ar1() / toeph()。参考结果(已实跑):AIC 分别 1265.90 / 1263.42 / 1280.07 / 1265.71,BIC 1280.19 / 1268.19 / 1284.83 / 1277.62 ⇒ CS 在本数据上最优(参数少、拟合几乎不差);Week 12 校正差在 8.80–8.96 之间,结论对协方差结构不敏感。想一想:为什么 REML 下不能这样比 AIC?(提示:REML 似然是"固定效应被投影掉"之后的似然,只在固定效应完全相同时可比。)

下一章:统计结果怎么变成能递交的表格与图——rtables / flextable / Quarto 搭一条 TLF 流水线,把本章的 KM 表、Cox 表、MMRM 表一次性产出并双编程比对(第 4 章)。