统计分析:从生存曲线到 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
ggplot(ae, aes(...)):data 层指定"画哪个数据框",mapping 层(aes())指定"哪个变量走哪个视觉通道"——x=AEBODSYS走横轴,fill=TRT01P走填充色。SAS 里这两件事分别对应DATA=与VBAR AEBODSYS / GROUP=TRT01P。geom_bar(position = "dodge"):geom 层决定"用什么几何体、怎么摆"。geom_bar()默认stat="count",也就是自己数行数,你不需要先跑一遍频数;position="dodge"让同一 SOC 的三个治疗组并排(SAS 里是GROUPDISPLAY=CLUSTER)。想堆叠就换position="stack",想看比例就换position="fill"。labs():轴标题、图标题、图例标题。对应 SAS 的LABEL=/XAXISLABEL=,但 R 里"标签"是图层的一部分,可以被后面的图层覆盖。theme_minimal(base_size = 12):theme 层只管长相(网格线、字号、背景),不管数据。类比 ODS 图形样式(ODS GRAPHICS / RESET=ATTRVAR或STYLEATTRS)。theme(axis.text.x = element_text(angle = 45, hjust = 1)):theme 是"局部覆盖全局"的——先套一个整体主题,再单独把 x 轴标签转 45°。hjust=1让标签右对齐到刻度上,这是 SOC 这类长标签的标准处理。ggsave():把图落盘。递交里请用 ggsave,不要用截图:它显式记录width/height/dpi,图形尺寸可复现、可写进程序头注释。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
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) 这一句在图层语法里扮演什么角色?
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
Surv(time, event)返回的是class = "Surv"的 228 × 2 矩阵:第 1 列是时间,第 2 列(列名status)是事件指示。attr(s, "type") = "right"表示右删失——正是 LIFETEST 的默认设定。print(head(s, 10))里的1010+、1022+:加号就是删失。这一眼扫过去,你能立刻分辨"到 1010 天还活着(随访截尾)"和"到 1010 天发生事件"。这是 SAS 输出里没有的直观表达。lung$status的原始编码是 1 = 删失、2 = 死亡(反直觉!),所以lung$status == 2生成 TRUE/FALSE,Surv 自动转成 1 = 事件、0 = 删失,共 165 个事件、63 个删失。- 事件编码的铁律: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 编码查清楚——这一步在哪个语言里都省不掉。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 写法?
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
survfit(Surv(time, event) ~ sex, data = lung):公式右边是分组变量,等价于 SAS 的STRATA sex;(注意:不是class,是分层出多条 KM 曲线)。print(fit)就是 LIFETEST 输出里最常被引用的那几行:男性(sex=1)138 例、112 个事件、中位生存 270 天(95% CI 212–310);女性 90 例、53 个事件、中位 426 天(348–550)。这个表可以直接进 TLF 的"生存总结"部分。summary(fit, times = 365)$surv:取指定时间点的生存率——365 天时男性 0.336、女性 0.526。这就是"1 年生存率",SAS 里对应TIMELIST=(365)或 SURVIVALPLOT 上的读数。要 CI 就取summary(fit, times=365)$lower / $upper。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 一致。ggsurvplot()参数:pval=TRUE在图上打 log-rank p 值,conf.int=TRUE画 95% 置信带,risk.table=TRUE在曲线下方加"风险人数表"(递交图几乎必备),legend.labs把 sex=1/2 翻译成人话。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 写的是哪个,就用哪个。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
ties method: efron:处理同一时间多个事件(ties)的方法。coxph默认 Efron,PROC PHREG 的TIES=默认也是 EFRON——这一点两边天然对齐,不用特意设置。要复现老程序的TIES=BRESLOW,写coxph(..., ties = "breslow")。coef是对数风险比;exp(coef)才是 HR,也就是 PHREG 输出里的 "Hazard Ratio" 列。se(coef)、z、Pr(>|z|)三列与 PHREG 的 "Pr > |z|" 完全同义(都是 Wald 检验)。- 怎么读这三个 HR:
ageHR = 1.011(95% CI 0.993–1.030,p = 0.23)——每大 1 岁风险约高 1.1%,但不显著;ph.ecogHR = 1.590(1.273–1.986,p < 0.001)——ECOG 每恶化 1 级,死亡风险约升 59%,是最强预后因子;sexHR = 0.575(0.414–0.799,p = 0.001)。 concordance = 0.637:C 指数(判别力),相当于把所有可比较的病对里"模型排序正确"的比例,0.5 = 瞎猜、1 = 完美。SAS 里要额外算(%concordance宏或 PHREG 无直接输出),R 里summary()白送。n = 227, events = 164:分析集与事件数——写进表格脚注的两个数。原始 228 行里有 1 行ph.ecog缺失被删掉。
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,正确解读是?
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
- 数据是长格式(一人一访视一行),240 行减去 14 条脱落 = 226 行、80 个受试者。这正是 PROC MIXED 需要的形状,也是 ADaM ADLB/ADVSS 的形状。
- 真实效应设定为:Active 组每个访视额外增加
2.5 × AVISITN,即 Week 4 差 2.5、Week 8 差 5.0、Week 12 差 7.5。后面看到估计值 8.8 时,你知道那是抽样波动,不是模型错。 sub$bi是受试者级随机截距(SD = 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
- 公式左边
FEV1 ~ ARM + AVISIT + ARM:AVISIT + BASE是固定效应:治疗主效应、访视主效应、治疗 × 访视交互、基线校正。SAP 里那句"MMRM with treatment, visit, treatment-by-visit interaction and baseline as covariates"就是它。 us(AVISIT | USUBJID)是随机效应/协方差部分:us()= unstructured 非结构协方差,AVISIT= 在哪个维度上建协方差(访视),USUBJID= 谁是重复测量的个体。整体对应 SAS 的REPEATED AVISIT / SUBJECT=USUBJID TYPE=UN;。Covariance: unstructured (6 variance parameters):3 个访视的非结构矩阵 = 3 个方差 + 3 个协方差 = 6 个参数(n(n+1)/2)。它不假设相关随时间衰减,因此最"贵"也最保守——这是监管场景的默认选择。Inference: REML:与 PROC MIXED 默认一致。注意:REML 的似然不能用于比较固定效应不同的模型,要比 AIC 需reml = FALSE(3.6 节和本章练习会用到)。Converged with code 0:收敛信息。SAS 里你会去 log 里找 "Convergence criteria met";R 里print(fit)直接印出来,fit$opt_details里还有更多细节。没确认收敛就不要往下读结果。- 系数里
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
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),同一批数、不同排版)。confint(fit)默认用 Satterthwaite 自由度,等价于 PROC MIXED 的DDFM=SATTERTHWAITE。想要 Kenward-Roger 就confint(fit, method = "KR")(对应DDFM=KR)。- 关键陷阱:
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。 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。df = 75.5是小数——这正是 Satterthwaite 调整的特征(PROC MIXED 也会给 75.x 这种小数自由度)。看到整数 df 反而要警惕是不是退化成 z 检验了。
| SAS PROC MIXED | R 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 Estimates | VarCorr(fit) | 协方差参数 |
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 的作用是什么?
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 | USUBJID):竖线在这里的含义与 mmrm 不同——左边是随机效应项(截距 1),右边是分组变量。整句意思是"每个受试者有一个自己的随机截距"。- 随机截距 ⇒ 协方差矩阵形如
σ²_b·J + σ²·I,即复合对称(CS):所有访视方差相同、任意两次测量相关相同。这里σ_b = 3.75、σ = 2.94,隐含相关 ≈ 3.75²/(3.75²+2.94²) ≈ 0.62。只有 2 个协方差参数,而 mmrm 的 US 用了 6 个。 - 固定效应点估计与 mmrm 几乎相同(
ARMActive都是 3.017,BASE0.986 vs 0.973),但标准误不同(1.066 vs mmrm 的 Satterthwaite SE),且 lmer 的summary()默认不给 p 值——这是 lme4 作者的刻意设计(分母自由度无公认定义)。要 p 值请装lmerTest,它把lmer对象升级成带 Satterthwaite/KR 自由度的版本。 - 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) 一句搞定 |
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
chisq.test(tab):Pearson 卡方,X-squared = 6.3209、df = 1、p = 0.0119。粗 OR = (42×35)/(18×30) = 2.722,即 Active 组响应优势约为 Placebo 的 2.7 倍。- 注意输出标题里的 "with Yates' continuity correction":R 对 2×2 表默认做耶茨连续性校正,PROC FREQ 的
/CHISQ不校正。要复现 SAS 的 7.2645 / p = 0.00703,必须写chisq.test(tab, correct = FALSE)(两个数都实跑验证过)。这是双编程比对里"p 值总差一点"的头号原因。 mantelhaen.test(cen):输入是2 × 2 × K数组,第三维就是 SASSTRATA语句里的分层变量。输出三件东西:CMH 卡方(6.1387,p = 0.0132)、合并 OR 及其 95% CI(2.745,1.305–5.774)、以及备择假设说明。- 粗 OR 2.722 与分层 OR 2.745 几乎相同 ⇒ 中心不是明显的混杂因素,也没有强烈的效应异质性。这正是 CMH 的用途:在控制分层变量的同时给出一个合并估计。若两者差异很大,就要回头查中心效应(并考虑 Breslow-Day 同质性检验,
mantelhaen.test不提供,需用DescTools::BreslowDayTest())。 - 对应 SAS:
PROC FREQ; TABLES TRT*RESP / CHISQ CMH RELRISK; STRATA CENTER;——/CMH输出的 "Mantel-Haenszel Chi-Square" 与 "Common Odds Ratio" 就是这里两行。
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,最可能的原因是?
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)
`HR (95% CI)`与` `:反引号让 R 接受带空格/括号的列名。` `(一个空格)就是留给图形的空白列,check.names = FALSE防止 R 把它改名成X.。est / lower / upper是数值向量,顺序与 df 的行一一对应;而HR (95% CI)那一列是给人看的字符串。图形由数值画,文字由字符串显示,两者要你自己保证一致(这就是 QC 检查点)。ci_column = 4:图形画在第 4 列(即那个空格列)。ref_line = 1:无效线在 1(HR/OR/RR 用 1;若是均数差则用 0)。xlim = c(0.4, 1.4):横轴范围,务必覆盖所有 CI 上下限,否则线会被截断。forest_theme():主题对象。注意用refline_gp = gpar(...);旧参数refline_col已弃用,会打印警告(本例输出中已无该警告,因为我们改用了新参数)。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()),这条路我们也验证过可行。- Subgroup 里
" Age < 65"的前导空格是手工缩进:森林图的层级感(Overall 顶格、亚组缩进)就靠字符串排版实现。
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
k = 2:总共 2 次分析(期中 + 最终);timing = 1表示期中安排在信息量 50% 处(输出中 Analysis 1 的 Sample Size Ratio = 0.504)。- Z 边界:期中
Z = 2.75(名义 p = 0.0030),最终Z = 1.98(名义 p = 0.0238)。Spend 列是这次分析"花掉"的 α:0.003 + 0.022 = Total 0.0250,正好等于预算,一分不多。 - 这就是"看数据的代价":最终分析的临界 p 从固定设计的 0.025 收紧到 0.0238。你的 SAP 必须写明用的是这个边界,而不是 0.025。
- Sample Size Ratio = 1.009:为了换取一次期中分析的机会,最大信息量(事件数/样本量)只需比固定设计多 0.9%——期中分析其实很便宜。
- 下半部分的越界概率表:
Theta = 0.0000是原假设成立的情形,两次分析累计越上界概率 0.0030 + 0.0220 = 0.025,即 I 类错误被精确控制在 2.5%;Theta = 3.2415是备择假设(对应 90% power)的情形,累计越界 0.3271 + 0.5729 = 0.900,即 power 达标。 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
graph 化的 α 传播,见 gsDesign 的 gsDesignGG 相关功能与 gMCP 包)。测验 6:为什么最终分析的名义 p 值边界是 0.0238 而不是 0.025?
章末资源
- 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 部分的延伸阅读 参考
本章练习
- (易)用
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。 - (中)在 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.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 章)。