关于本教程的数据 所有个体、事件时间、删失、竞争事件和治疗变化均由固定随机种子模拟,不含真实个人健康信息。主队列特意包含随时间变化的干预效应,用于展示交叉生存曲线、log-rank 检验与单一 HR 的局限。所有数值仅供教学,不能作为真实临床或因果证据。
本教程以“从时间零点到首次研究终点”为主线,但不会把生存分析缩减成一条 Kaplan–Meier 曲线或一个 Cox 模型。推荐按以下顺序学习:
问题与目标估计量 → 时间结构与删失 → 非参数描述 → 组间比较 → 回归与诊断 → 复杂事件过程 → 设计与报告
先读每节的研究问题,再运行 R
代码并解释输出。知识检查与练习默认折叠。代码只依赖 R 自带功能和
survival 包;knitr
仅用于生成教学页面中的表格。
完成本教程后,你应能够:
生存分析同时关心事件是否发生以及何时发生。开始计算前,应把下列要素写进研究方案:
| 要素 | 需要明确的问题 | 本教程主队列中的定义 |
|---|---|---|
| 目标人群 | 结果要推广到谁? | 符合模拟项目纳入条件的成年人 |
| 时间零点 | 从哪一刻开始处于风险中? | 随机分配管理策略的日期 |
| 时间尺度 | 随访月数、年龄还是日历时间? | 从分组起计算的月数 |
| 事件 | 哪个可重复判定的终点? | 首次达到模拟研究终点 |
| 竞争事件 | 什么会阻止目标事件发生? | 主队列无;后文另行模拟 |
| 观察终点 | 何时停止观察? | 事件、失访或行政截止 |
| 分析单位 | 每行代表谁或什么? | 主分析中每名参与者一行 |
时间零点、资格判定和策略分配应尽可能对齐。若把必须先存活到接受治疗者归为治疗组,却从更早的诊断日开始计时,就会产生一段“必然存活”的时间,形成不死时间偏倚。
同一研究问题可以在不同尺度上回答:
| 目标估计量 | 回答的问题 | 典型表达 |
|---|---|---|
| 12 个月仍未发生事件的概率是多少? | 12 个月生存概率与组间差 | |
| 无竞争风险时,12 个月前发生事件的概率是多少? | 12 个月累积事件风险 | |
| 中位事件时间 | 何时有 50% 的人发生事件? | 月数;可能“尚未达到” |
| RMST(τ) | 到 τ 为止平均有多少无事件时间? | τ 内平均无事件月数及组间差 |
| HR | 仍处于风险者的条件瞬时事件率相差多少? | Cox PH 或时间特异 HR |
| TR | 条件事件时间分位数被乘以多少? | AFT 时间比 |
| 累积发生函数 | 存在竞争事件时,目标原因在 前的现实概率是多少? | 原因特异累积发生概率 |
不同估计量不是同一答案的不同写法。HR 不等于风险比,TR 不等于 HR,RMST 差也不会由单一 HR 唯一决定。最能支持决策的估计量应在查看结果前预先指定。
不要让 p 值替你选择研究问题 不能在 log-rank、Cox、AFT 和 RMST 中挑选 p 值最小者,再把相应尺度称为“主要结果”。这种做法改变了问题并放大选择性报告。主要估计量来自科学问题;其他尺度可作为预先规划的补充或敏感性分析。
若 Cox 模型给出 HR=0.70,能否直接写成“12 个月事件风险降低 30%”?
答案:不能。HR 是在仍处于风险中的人之间比较条件瞬时事件率;12 个月风险是从时间零点累计到 12 个月的概率。应从相应生存概率计算 12 个月风险或风险差。设真实事件时间为 ,右删失时间为 。通常观察到:
当 时观察到事件;当 时,只知道真实事件时间晚于观察时间。删失者不是“没有事件”,更不能把事件时间记为无穷大。右删失方法保留删失之前的无事件随访信息,并在删失之后把该个体移出风险集。
Surv(time, event) 对数值状态通常使用
1=事件、0=删失。应在建模前主动检查,而不是依赖软件猜测。
stopifnot(
all(study_data$time_months > 0),
all(study_data$event %in% c(0, 1)),
!anyNA(study_data)
)
preview <- transform(
head(study_data[, c(
"participant_id", "time_months", "event",
"treatment", "age", "severity", "biomarker"
)]),
event = ifelse(event == 1, "事件", "右删失"),
treatment = treatment_labels[as.character(treatment)],
severity = severity_labels[as.character(severity)]
)
knitr::kable(
preview,
col.names = c(
"参与者", "观察时间(月)", "观察终点", "管理策略",
"年龄", "基线严重度", "标准化生物标志物"
),
caption = "模拟主队列的前 6 行"
)| 参与者 | 观察时间(月) | 观察终点 | 管理策略 | 年龄 | 基线严重度 | 标准化生物标志物 |
|---|---|---|---|---|---|---|
| P001 | 1.06 | 事件 | 标准管理 | 73 | 轻度 | 0.59 |
| P002 | 4.34 | 事件 | 标准管理 | 48 | 轻度 | -1.08 |
| P003 | 10.17 | 事件 | 干预 | 37 | 轻度 | -0.63 |
| P004 | 19.39 | 右删失 | 标准管理 | 49 | 中度 | 2.11 |
| P005 | 4.24 | 事件 | 标准管理 | 56 | 轻度 | 0.62 |
| P006 | 22.77 | 右删失 | 标准管理 | 49 | 重度 | -1.39 |
followup_audit <- data.frame(
样本量 = nrow(study_data),
事件数 = sum(study_data$event),
右删失数 = sum(study_data$event == 0),
右删失比例 = pct(mean(study_data$event == 0)),
最短观察月数 = min(study_data$time_months),
最长观察月数 = max(study_data$time_months),
check.names = FALSE
)
knitr::kable(
followup_audit,
digits = 2,
caption = "主队列的随访审计"
)| 样本量 | 事件数 | 右删失数 | 右删失比例 | 最短观察月数 | 最长观察月数 |
|---|---|---|---|---|---|
| 720 | 575 | 145 | 20.1% | 0.12 | 29.8 |
描述随访时,至少同时报告样本量、事件数、删失数、观察范围和关键时点的风险人数。只报“中位随访时间”会隐藏删失发生在何时以及后段估计由多少人支持。
在每个不同事件时点 ,设事件数为 ,时点之前的风险人数为 。Kaplan–Meier(KM)估计为:
事件使曲线向下跳,删失本身不使曲线下降,但会减少之后的风险人数。KM 曲线的水平部分不表示已知“没有风险”,只表示该区间没有观察到事件。
survival_outcome <- with(
study_data,
survival::Surv(time_months, event)
)
km_fit <- survival::survfit(
survival_outcome ~ treatment,
data = study_data,
conf.type = "log-log"
)
plot(
km_fit,
col = c(palette_sa["orange"], palette_sa["teal"]),
lwd = 2.4,
mark.time = TRUE,
conf.int = FALSE,
xlab = "分组后的月数",
ylab = "仍未发生研究终点的估计概率",
xlim = c(0, 30),
ylim = c(0, 1),
las = 1
)
abline(v = 12, lty = 3, col = palette_sa["gray"])
legend(
"topright",
legend = c("标准管理", "干预", "预设的 12 个月分界"),
col = c(
palette_sa["orange"],
palette_sa["teal"],
palette_sa["gray"]
),
lty = c(1, 1, 3),
lwd = c(2.4, 2.4, 1.2),
bty = "n"
)按管理策略分组的 Kaplan–Meier 生存曲线。短竖线标记右删失;两条曲线前期分离、后期靠近并交叉,提示单一比例效应可能不合适。
图形必须与风险人数一起读。曲线尾部若只剩很少参与者,一个事件就会造成很大跳跃,置信区间也会变宽。
km_selected <- summary(
km_fit,
times = c(6, 12, 18, 24)
)
km_selected_table <- data.frame(
管理策略 = treatment_labels[
sub("treatment=", "", as.character(km_selected$strata))
],
时间月 = km_selected$time,
风险人数 = km_selected$n.risk,
生存概率 = km_selected$surv,
`95% CI 下限` = km_selected$lower,
`95% CI 上限` = km_selected$upper,
check.names = FALSE
)
knitr::kable(
km_selected_table,
digits = 3,
caption = "预先选择时点的 KM 生存概率、风险人数与 95% 置信区间"
)| 管理策略 | 时间月 | 风险人数 | 生存概率 | 95% CI 下限 | 95% CI 上限 |
|---|---|---|---|---|---|
| 标准管理 | 6 | 226 | 0.630 | 0.577 | 0.677 |
| 标准管理 | 12 | 157 | 0.437 | 0.386 | 0.488 |
| 标准管理 | 18 | 88 | 0.278 | 0.233 | 0.326 |
| 标准管理 | 24 | 32 | 0.216 | 0.172 | 0.263 |
| 干预 | 6 | 290 | 0.803 | 0.758 | 0.841 |
| 干预 | 12 | 231 | 0.640 | 0.588 | 0.687 |
| 干预 | 18 | 94 | 0.286 | 0.239 | 0.333 |
| 干预 | 24 | 22 | 0.155 | 0.115 | 0.201 |
km_risk_summary <- summary(
km_fit,
times = c(0, 6, 12, 18, 24)
)
km_risk_long <- data.frame(
管理策略 = treatment_labels[
sub("treatment=", "", as.character(km_risk_summary$strata))
],
时间月 = km_risk_summary$time,
风险人数 = km_risk_summary$n.risk
)
km_risk_table <- xtabs(
风险人数 ~ 管理策略 + 时间月,
data = km_risk_long
)
knitr::kable(
km_risk_table,
caption = "各时点之前仍在风险集中的人数"
)| 0 | 6 | 12 | 18 | 24 | |
|---|---|---|---|---|---|
| 干预 | 361 | 290 | 231 | 94 | 22 |
| 标准管理 | 359 | 226 | 157 | 88 | 32 |
km_median_raw <- summary(km_fit)$table
km_median_table <- data.frame(
管理策略 = treatment_labels[
sub("treatment=", "", rownames(km_median_raw))
],
中位事件时间月 = km_median_raw[, "median"],
`95% CI 下限` = km_median_raw[, "0.95LCL"],
`95% CI 上限` = km_median_raw[, "0.95UCL"],
check.names = FALSE
)
knitr::kable(
km_median_table,
digits = 2,
caption = "按管理策略计算的未调整 KM 中位事件时间"
)| 管理策略 | 中位事件时间月 | 95% CI 下限 | 95% CI 上限 | |
|---|---|---|---|---|
| Standard | 标准管理 | 10.3 | 8.42 | 11.8 |
| Intervention | 干预 | 14.0 | 13.16 | 15.2 |
若曲线在观察期内始终高于 0.50,中位事件时间应报告为“尚未达到”,不能用最后观察时间替代。比较曲线时也不应只比较两个中位数,因为它们忽略了其余随访过程。
Nelson–Aalen 估计在每个事件时点累加 hazard 增量:
KM 直接估计生存函数,Nelson–Aalen 直接估计累积 hazard。利用生存函数与累积 hazard 的指数关系可在两个尺度间转换;当每次事件增量较小时,两种生存估计通常很接近,但并非代数上完全相同。
na_fit <- survival::survfit(
survival_outcome ~ treatment,
data = study_data,
ctype = 1
)
plot(
na_fit,
fun = "cumhaz",
col = c(palette_sa["orange"], palette_sa["teal"]),
lwd = 2.4,
conf.int = FALSE,
xlab = "分组后的月数",
ylab = "Nelson–Aalen 累积 hazard",
xlim = c(0, 30),
las = 1
)
abline(v = 12, lty = 3, col = palette_sa["gray"])
legend(
"topleft",
legend = c("标准管理", "干预"),
col = c(palette_sa["orange"], palette_sa["teal"]),
lwd = 2.4,
bty = "n"
)按管理策略分组的 Nelson–Aalen 累积 hazard 曲线。曲线的斜率反映事件累积速度;干预组后期斜率变陡,与 KM 曲线后期快速下降相呼应。
na_selected <- summary(
na_fit,
times = c(6, 12, 18, 24)
)
na_table <- data.frame(
管理策略 = treatment_labels[
sub("treatment=", "", as.character(na_selected$strata))
],
时间月 = na_selected$time,
累积_hazard = na_selected$cumhaz,
标准误 = na_selected$std.chaz,
check.names = FALSE
)
knitr::kable(
na_table,
digits = 3,
col.names = c("管理策略", "时间(月)", "累积 hazard", "标准误"),
caption = "选定时点的 Nelson–Aalen 累积 hazard"
)| 管理策略 | 时间(月) | 累积 hazard | 标准误 |
|---|---|---|---|
| 标准管理 | 6 | 0.462 | 0.040 |
| 标准管理 | 12 | 0.825 | 0.060 |
| 标准管理 | 18 | 1.275 | 0.085 |
| 标准管理 | 24 | 1.525 | 0.107 |
| 干预 | 6 | 0.219 | 0.026 |
| 干预 | 12 | 0.446 | 0.039 |
| 干预 | 18 | 1.249 | 0.084 |
| 干预 | 24 | 1.854 | 0.141 |
标准 log-rank 检验在每个事件时点比较各组观察事件数与零假设下的期望事件数,再把差异按方差标准化。两组时可概括为:
在零假设与相应删失假设下, 近似服从 1 个自由度的卡方分布。log-rank 检验比较的是整个生存过程,不估计效应大小;它在比例风险型差异下通常较有力,但曲线交叉时,早期与晚期差异可能互相抵消。
logrank_fit <- survival::survdiff(
survival::Surv(time_months, event) ~ treatment,
data = study_data,
rho = 0
)
logrank_df <- length(logrank_fit$n) - 1
logrank_p <- pchisq(
logrank_fit$chisq,
df = logrank_df,
lower.tail = FALSE
)
logrank_table <- data.frame(
管理策略 = treatment_labels[
sub("treatment=", "", names(logrank_fit$n))
],
样本量 = as.numeric(logrank_fit$n),
观察事件数 = as.numeric(logrank_fit$obs),
零假设期望事件数 = as.numeric(logrank_fit$exp),
check.names = FALSE
)
knitr::kable(
logrank_table,
digits = 1,
caption = "log-rank 检验中的观察与期望事件数"
)| 管理策略 | 样本量 | 观察事件数 | 零假设期望事件数 | |
|---|---|---|---|---|
| Standard | 标准管理 | 359 | 279 | 263 |
| Intervention | 干预 | 361 | 296 | 312 |
logrank_result <- data.frame(
卡方统计量 = unname(logrank_fit$chisq),
自由度 = logrank_df,
`p 值` = format_p(logrank_p),
check.names = FALSE
)
knitr::kable(
logrank_result,
digits = 3,
caption = "标准 log-rank 检验"
)| 卡方统计量 | 自由度 | p 值 |
|---|---|---|
| 1.78 | 1 | 0.182 |
本例 log-rank p 值为 0.182。这并不表示两组在所有时间都相同:12 个月时干预曲线明显较高,而 24 个月时差异反向。标准 log-rank 把这些方向相反的差异汇总,可能发生抵消。
固定时点结果直接回答“到这一时点仍无事件的概率”。本例展示为何必须预先指定时间点,而不能只选择差异最大的时点。
fixed_time_table <- km_selected_table[
km_selected_table$时间月 %in% c(12, 24),
]
knitr::kable(
fixed_time_table,
digits = 3,
caption = "12 与 24 个月的未调整 KM 生存概率"
)| 管理策略 | 时间月 | 风险人数 | 生存概率 | 95% CI 下限 | 95% CI 上限 | |
|---|---|---|---|---|---|---|
| 2 | 标准管理 | 12 | 157 | 0.437 | 0.386 | 0.488 |
| 4 | 标准管理 | 24 | 32 | 0.216 | 0.172 | 0.263 |
| 6 | 干预 | 12 | 231 | 0.640 | 0.588 | 0.687 |
| 8 | 干预 | 24 | 22 | 0.155 | 0.115 | 0.201 |
12 个月时,标准管理与干预的 KM 生存概率分别为 43.7% 和 64.0%;到 24 个月,两组排序已经反转。这种时间异质性不能由一个“总体最好”的百分比概括。
限制平均生存时间(restricted mean survival time,RMST)是生存曲线从 0 到预先指定截点 τ 下的面积:
它可解释为到 τ 为止的平均无事件时间。RMST 差以原时间单位表达,不要求比例风险,但结果依赖 τ;截点应由临床问题和共同随访支持范围预先确定。
rmst_tau <- 24
rmst_raw <- summary(km_fit, rmean = rmst_tau)$table
rmst_row_order <- match(
paste0("treatment=", levels(study_data$treatment)),
rownames(rmst_raw)
)
rmst_raw <- rmst_raw[rmst_row_order, , drop = FALSE]
rmst_group_table <- data.frame(
管理策略 = treatment_labels[levels(study_data$treatment)],
RMST月 = rmst_raw[, "rmean"],
标准误 = rmst_raw[, "se(rmean)"],
check.names = FALSE
)
rmst_difference <-
rmst_group_table$RMST月[2] - rmst_group_table$RMST月[1]
rmst_difference_se <- sqrt(sum(rmst_group_table$标准误^2))
rmst_difference_ci <- rmst_difference +
c(-1, 1) * qnorm(0.975) * rmst_difference_se
rmst_difference_p <- 2 * pnorm(
-abs(rmst_difference / rmst_difference_se)
)
rmst_difference_table <- data.frame(
对比 = "干预 - 标准管理",
`24 个月 RMST 差(月)` = rmst_difference,
`95% CI 下限` = rmst_difference_ci[1],
`95% CI 上限` = rmst_difference_ci[2],
`p 值` = format_p(rmst_difference_p),
check.names = FALSE
)
knitr::kable(
rmst_group_table,
digits = 2,
col.names = c("管理策略", "24 个月 RMST(月)", "标准误"),
caption = "由 KM 曲线计算的 24 个月限制平均无事件时间"
)| 管理策略 | 24 个月 RMST(月) | 标准误 | |
|---|---|---|---|
| Standard | 标准管理 | 11.6 | 0.45 |
| Intervention | 干预 | 13.7 | 0.38 |
| 对比 | 24 个月 RMST 差(月) | 95% CI 下限 | 95% CI 上限 | p 值 |
|---|---|---|---|---|
| 干预 - 标准管理 | 2.08 | 0.925 | 3.23 | <0.001 |
到 24 个月为止,干预组的平均无事件时间估计比标准管理组长 2.08 个月(95% CI:0.92 至 3.23)。这与 log-rank p 值不矛盾:RMST 差是曲线面积的效应估计,而 log-rank 是对整条曲线的加权检验;交叉曲线下二者关注的信息不同。
RMST 不是“模型假设为零”的方法 RMST 仍依赖事件与删失定义、共同时间范围和删失机制。若截点 τ 之后几乎无人留在风险集中,或只因为某个截点显著才选择它,解释仍不可靠。调整后 RMST 还需要标准化、加权或合适的生存模型。
| 模型 | 基本形式 | 主要效应量 | 核心效应假设 |
|---|---|---|---|
| Cox PH | HR | HR 在时间上恒定 | |
| AFT | TR | 条件时间分布按固定倍数缩放 |
Cox 不需要为基线 hazard 指定参数形状,但仍依赖 PH、协变量函数形式与条件独立删失。参数 AFT 直接给出时间比和分位数,但必须选择事件时间分布。两者的系统推导、参数化、诊断与换算见 AFT 与 Cox PH 专题;本综合页只演示如何把模型放回完整分析流程。
cox_fit <- survival::coxph(
survival::Surv(time_months, event) ~
treatment + age10 + severity + biomarker,
data = study_data,
ties = "efron",
x = TRUE,
y = TRUE
)
cox_summary <- summary(cox_fit)
cox_term_labels <- c(
treatmentIntervention = "干预 vs 标准管理",
age10 = "年龄每增加 10 岁",
severityModerate = "中度 vs 轻度",
severitySevere = "重度 vs 轻度",
biomarker = "生物标志物每增加 1 SD"
)
cox_terms <- rownames(cox_summary$coefficients)
cox_table <- data.frame(
变量 = unname(cox_term_labels[cox_terms]),
HR = cox_summary$coefficients[, "exp(coef)"],
`95% CI 下限` = cox_summary$conf.int[, "lower .95"],
`95% CI 上限` = cox_summary$conf.int[, "upper .95"],
`p 值` = vapply(
cox_summary$coefficients[, "Pr(>|z|)"],
format_p,
character(1)
),
check.names = FALSE
)
knitr::kable(
cox_table,
digits = 3,
caption = "假定恒定 HR 的调整后 Cox 模型"
)| 变量 | HR | 95% CI 下限 | 95% CI 上限 | p 值 | |
|---|---|---|---|---|---|
| treatmentIntervention | 干预 vs 标准管理 | 0.858 | 0.727 | 1.01 | 0.0679 |
| age10 | 年龄每增加 10 岁 | 1.187 | 1.102 | 1.28 | <0.001 |
| severityModerate | 中度 vs 轻度 | 1.179 | 0.983 | 1.41 | 0.075 |
| severitySevere | 重度 vs 轻度 | 1.523 | 1.208 | 1.92 | <0.001 |
| biomarker | 生物标志物每增加 1 SD | 1.145 | 1.056 | 1.24 | 0.00106 |
表中的干预 HR 是整个随访期的单一汇总。KM 曲线已经提示效应变号,因此在解释前必须检查 PH;不能因软件顺利输出一个 HR 就默认它恒定。
cox.zph() 检查缩放 Schoenfeld 残差是否随时间系统变化。小
p 值提示相应 log HR 可能随时间变化;大 p 值只是“没有强烈反证”,不是 PH
已被证明。
ph_check <- survival::cox.zph(cox_fit, transform = "km")
ph_table <- data.frame(
检验项 = c("管理策略", "年龄", "基线严重度", "生物标志物", "全局"),
卡方统计量 = ph_check$table[, "chisq"],
自由度 = ph_check$table[, "df"],
`p 值` = vapply(ph_check$table[, "p"], format_p, character(1)),
check.names = FALSE
)
knitr::kable(
ph_table,
digits = 3,
caption = "基于缩放 Schoenfeld 残差的比例风险检验"
)| 检验项 | 卡方统计量 | 自由度 | p 值 | |
|---|---|---|---|---|
| treatment | 管理策略 | 45.555 | 1 | <0.001 |
| age10 | 年龄 | 3.760 | 1 | 0.0525 |
| severity | 基线严重度 | 0.434 | 2 | 0.805 |
| biomarker | 生物标志物 | 1.023 | 1 | 0.312 |
| GLOBAL | 全局 | 50.879 | 5 | <0.001 |
plot(
ph_check,
var = 1,
resid = TRUE,
se = TRUE,
col = palette_sa["teal"]
)
abline(h = 0, lty = 2, col = palette_sa["gray"])
title("管理策略的时间变化效应")管理策略的缩放 Schoenfeld 残差诊断。平滑曲线明显偏离水平线,说明单一干预 log HR 不能概括整个随访期。
本例管理策略与全局检验均显示明显偏离。下面按预设的 12 个月机制估计早期与晚期 HR。真实分析中的分界必须由临床机制或方案预先指定,不能从曲线中反复寻找最显著切点。
# survSplit() 要求公式左侧的函数名称直接写作 Surv。
Surv <- survival::Surv
piecewise_data <- survival::survSplit(
Surv(time_months, event) ~ .,
data = study_data,
cut = 12,
start = "tstart",
end = "time_months"
)
rm(Surv)
piecewise_data$after12 <- as.integer(piecewise_data$tstart >= 12)
piecewise_cox <- survival::coxph(
survival::Surv(tstart, time_months, event) ~
treatment_num + I(treatment_num * after12) +
age10 + severity + biomarker + cluster(participant_id),
data = piecewise_data,
ties = "efron"
)
piecewise_names <- c(
"treatment_num",
"I(treatment_num * after12)"
)
piecewise_beta <- coef(piecewise_cox)[piecewise_names]
piecewise_vcov <- vcov(piecewise_cox)[piecewise_names, piecewise_names]
contrast_matrix <- rbind(early = c(1, 0), late = c(1, 1))
period_log_hr <- drop(contrast_matrix %*% piecewise_beta)
period_se <- sqrt(diag(
contrast_matrix %*% piecewise_vcov %*% t(contrast_matrix)
))
period_hr_table <- data.frame(
时段 = c("0 至 ≤12 个月", ">12 个月"),
HR = exp(period_log_hr),
`95% CI 下限` = exp(period_log_hr - 1.96 * period_se),
`95% CI 上限` = exp(period_log_hr + 1.96 * period_se),
check.names = FALSE
)
knitr::kable(
period_hr_table,
digits = 3,
caption = "允许干预效应在 12 个月改变的调整后 Cox 模型"
)| 时段 | HR | 95% CI 下限 | 95% CI 上限 | |
|---|---|---|---|---|
| early | 0 至 ≤12 个月 | 0.505 | 0.404 | 0.631 |
| late | >12 个月 | 1.826 | 1.393 | 2.394 |
结果显示前 12 个月干预与较低瞬时事件率相关,之后方向反转。分段 HR 比单一 HR 更忠实于生成机制,但仍应与固定时点生存概率和 RMST 一起报告。
登记系统常在时间零点之后才纳入个体。只有满足事件时间晚于进入时间的人才可被观察,这叫左截断。若使用从共同起点计算的时间尺度,正确结局是
Surv(entry, exit, event);在 entry
之前,该人不能进入风险集。
set.seed(20260816)
n_source <- 1500
lt_age <- round(pmin(pmax(rnorm(n_source, 62, 10), 30), 88))
lt_age10 <- (lt_age - 60) / 10
lt_treatment_num <- rbinom(n_source, 1, 0.50)
lt_treatment <- factor(
lt_treatment_num,
levels = 0:1,
labels = c("标准管理", "干预")
)
# 干预组平均进入更晚,用于展示错误风险集带来的偏倚。
entry_time <- runif(n_source, 0, 7) + 2.5 * lt_treatment_num
lt_event_time <- rexp(
n_source,
rate = 0.045 * exp(0.25 * lt_age10 - 0.35 * lt_treatment_num)
)
lt_censor_time <- runif(n_source, 18, 30)
exit_time <- pmin(lt_event_time, lt_censor_time)
observed_after_entry <- exit_time > entry_time
lt_data <- data.frame(
id = seq_len(n_source),
entry = entry_time,
exit = exit_time,
event = as.integer(lt_event_time <= lt_censor_time),
treatment = lt_treatment,
age10 = lt_age10
)[observed_after_entry, ]
correct_lt_cox <- survival::coxph(
survival::Surv(entry, exit, event) ~ treatment + age10,
data = lt_data
)
naive_lt_cox <- survival::coxph(
survival::Surv(exit, event) ~ treatment + age10,
data = lt_data
)
lt_comparison <- data.frame(
分析 = c("正确:entry–exit 风险集", "错误:假定所有人从 0 起处于风险中"),
干预_HR = exp(c(
coef(correct_lt_cox)["treatment干预"],
coef(naive_lt_cox)["treatment干预"]
)),
check.names = FALSE
)
lt_risk_counts <- data.frame(
时间月 = c(2, 5, 8, 12),
正确风险人数 = vapply(
c(2, 5, 8, 12),
function(t) sum(lt_data$entry < t & lt_data$exit >= t),
numeric(1)
),
错误风险人数 = vapply(
c(2, 5, 8, 12),
function(t) sum(lt_data$exit >= t),
numeric(1)
),
check.names = FALSE
)
knitr::kable(lt_risk_counts, caption = "延迟进入下正确与错误的风险人数")| 时间月 | 正确风险人数 | 错误风险人数 |
|---|---|---|
| 2 | 184 | 1229 |
| 5 | 621 | 1183 |
| 8 | 940 | 1076 |
| 12 | 921 | 921 |
| 分析 | 干预_HR |
|---|---|
| 正确:entry–exit 风险集 | 0.754 |
| 错误:假定所有人从 0 起处于风险中 | 0.629 |
错误分析把尚未入组的人提前放入风险集,本例因两组进入时间不同而使干预看起来过度保护。延迟进入还要求:给定分析变量后,进入机制与后续事件过程的关系能够合理处理。
若治疗在随访中开始,基线时把“以后曾接受治疗”写成固定变量会使用未来信息,并把开始治疗前必须存活的时间错误归入治疗组。start–stop 数据在每次状态变化处分行,使每个区间使用区间开始时已知的值。
set.seed(20260817)
n_tv <- 900
tv_base <- data.frame(
id = seq_len(n_tv),
age10 = (round(pmin(pmax(rnorm(n_tv, 60, 10), 30), 85)) - 60) / 10
)
will_start <- rbinom(n_tv, 1, 0.72)
tv_base$planned_start <- ifelse(
will_start == 1,
runif(n_tv, 3, 12),
Inf
)
pre_rate <- 0.060 * exp(0.20 * tv_base$age10)
post_rate <- 0.032 * exp(0.20 * tv_base$age10)
pre_event <- rexp(n_tv, pre_rate)
post_event <- rexp(n_tv, post_rate)
tv_event_time <- ifelse(
pre_event <= tv_base$planned_start,
pre_event,
tv_base$planned_start + post_event
)
tv_censor <- runif(n_tv, 15, 26)
tv_base$time <- pmin(tv_event_time, tv_censor)
tv_base$event <- as.integer(tv_event_time <= tv_censor)
tv_long <- survival::tmerge(
data1 = tv_base,
data2 = tv_base,
id = id,
tstart = 0,
tstop = time,
outcome = event(time, event)
)
tv_long <- survival::tmerge(
data1 = tv_long,
data2 = tv_base,
id = id,
on_treatment = tdc(planned_start)
)
tv_cox <- survival::coxph(
survival::Surv(tstart, tstop, outcome) ~
on_treatment + age10 + cluster(id),
data = tv_long,
ties = "efron"
)
tv_base$ever_treated <- as.integer(tv_base$planned_start < tv_base$time)
immortal_time_cox <- survival::coxph(
survival::Surv(time, event) ~ ever_treated + age10,
data = tv_base,
ties = "efron"
)
tv_comparison <- data.frame(
分析 = c("正确:治疗作为时间变化协变量", "错误:基线使用以后曾治疗"),
HR = c(
exp(coef(tv_cox)["on_treatment"]),
exp(coef(immortal_time_cox)["ever_treated"])
)
)
knitr::kable(
head(tv_long[, c("id", "tstart", "tstop", "outcome", "on_treatment")], 8),
digits = 2,
caption = "时间变化治疗的 start–stop 数据结构示例"
)| id | tstart | tstop | outcome | on_treatment |
|---|---|---|---|---|
| 1 | 0.00 | 9.26 | 0 | 0 |
| 1 | 9.26 | 23.83 | 0 | 1 |
| 2 | 0.00 | 10.16 | 1 | 0 |
| 3 | 0.00 | 9.40 | 0 | 0 |
| 3 | 9.40 | 18.83 | 0 | 1 |
| 4 | 0.00 | 6.24 | 0 | 0 |
| 4 | 6.24 | 25.04 | 0 | 1 |
| 5 | 0.00 | 2.39 | 1 | 0 |
| 分析 | HR | |
|---|---|---|
| on_treatment | 正确:治疗作为时间变化协变量 | 0.554 |
| ever_treated | 错误:基线使用以后曾治疗 | 0.184 |
cluster(id)
在同一人贡献多行时给出聚类稳健方差;它不能修复未测量的时间变化混杂。若当前健康状态同时影响后续治疗与结局,普通时间变化
Cox 系数仍不自然等于因果治疗效应,可能需要边际结构模型等方法。
死亡若阻止复发,则死亡是复发的竞争事件。目标原因的累积发生函数为:
它同时取决于目标原因与其他原因的 hazard。把竞争死亡当普通删失后计算
1-KM,相当于假想死亡者仍可在以后复发,通常会高估现实复发概率。survival
对 factor 状态的 Surv()
使用多状态表示,survfit() 的 pstate 给出
Aalen–Johansen 状态概率。
set.seed(20260818)
n_cr <- 700
cr_treatment_num <- rbinom(n_cr, 1, 0.50)
cr_treatment <- factor(
cr_treatment_num,
levels = 0:1,
labels = c("标准管理", "干预")
)
recurrence_time <- rexp(
n_cr,
rate = 0.045 * ifelse(cr_treatment_num == 1, 0.68, 1)
)
death_time <- rexp(n_cr, rate = 0.026)
cr_censor_time <- runif(n_cr, 18, 30)
cr_time <- pmin(recurrence_time, death_time, cr_censor_time)
cr_code <- ifelse(
recurrence_time <= death_time & recurrence_time <= cr_censor_time,
1L,
ifelse(death_time < recurrence_time & death_time <= cr_censor_time, 2L, 0L)
)
# factor 的第一水平必须是删失;其余水平是不同事件状态。
cr_status <- factor(
cr_code,
levels = 0:2,
labels = c("Censor", "Recurrence", "Death")
)
cr_data <- data.frame(
id = seq_len(n_cr),
time = cr_time,
status = cr_status,
treatment = cr_treatment
)
aj_fit <- survival::survfit(
survival::Surv(time, status) ~ treatment,
data = cr_data
)
aj_summary <- summary(aj_fit, times = c(12, 24))
recurrence_column <- match("Recurrence", aj_summary$states)
stopifnot(!is.na(recurrence_column))
naive_recurrence_fit <- survival::survfit(
survival::Surv(time, status == "Recurrence") ~ treatment,
data = cr_data
)
naive_recurrence_summary <- summary(
naive_recurrence_fit,
times = c(12, 24)
)
stopifnot(
identical(
as.character(aj_summary$strata),
as.character(naive_recurrence_summary$strata)
),
identical(aj_summary$time, naive_recurrence_summary$time)
)
cr_table <- data.frame(
管理策略 = sub("treatment=", "", as.character(aj_summary$strata)),
时间月 = aj_summary$time,
Aalen_Johansen复发概率 = aj_summary$pstate[, recurrence_column],
`95% CI 下限` = aj_summary$lower[, recurrence_column],
`95% CI 上限` = aj_summary$upper[, recurrence_column],
标准误 = aj_summary$std.err[, recurrence_column],
错误的1减KM = 1 - naive_recurrence_summary$surv,
check.names = FALSE
)
knitr::kable(
cr_table,
digits = 3,
caption = "Aalen–Johansen 复发概率与把死亡当删失后的 1-KM"
)| 管理策略 | 时间月 | Aalen_Johansen复发概率 | 95% CI 下限 | 95% CI 上限 | 标准误 | 错误的1减KM |
|---|---|---|---|---|---|---|
| 标准管理 | 12 | 0.371 | 0.323 | 0.425 | 0.026 | 0.425 |
| 标准管理 | 24 | 0.505 | 0.454 | 0.562 | 0.028 | 0.643 |
| 干预 | 12 | 0.259 | 0.217 | 0.309 | 0.023 | 0.300 |
| 干预 | 24 | 0.401 | 0.352 | 0.456 | 0.027 | 0.521 |
# 用数值组索引保留拟合对象的 strata 顺序,避免 split() 按中文名称重排。
aj_index <- split(
seq_along(aj_fit$time),
rep(seq_along(aj_fit$strata), as.numeric(aj_fit$strata))
)
naive_index <- split(
seq_along(naive_recurrence_fit$time),
rep(
seq_along(naive_recurrence_fit$strata),
as.numeric(naive_recurrence_fit$strata)
)
)
group_colors <- c(palette_sa["orange"], palette_sa["teal"])
stopifnot(
identical(names(aj_fit$strata), names(naive_recurrence_fit$strata)),
length(aj_index) == length(group_colors)
)
plot(
NA,
xlim = c(0, 28),
ylim = c(0, 0.75),
xlab = "随访月数",
ylab = "复发累计发生概率",
las = 1
)
for (i in seq_along(aj_index)) {
idx <- aj_index[[i]]
lines(
c(0, aj_fit$time[idx]),
c(0, aj_fit$pstate[idx, match("Recurrence", aj_fit$states)]),
type = "s",
col = group_colors[i],
lwd = 2.4
)
naive_idx <- naive_index[[i]]
lines(
c(0, naive_recurrence_fit$time[naive_idx]),
c(0, 1 - naive_recurrence_fit$surv[naive_idx]),
type = "s",
col = group_colors[i],
lwd = 2.2,
lty = 2
)
}
legend(
"topleft",
legend = c(
"标准管理:Aalen–Johansen", "干预:Aalen–Johansen",
"标准管理:错误的 1-KM", "干预:错误的 1-KM"
),
col = c(group_colors, group_colors),
lty = c(1, 1, 2, 2),
lwd = 2.2,
bty = "n"
)存在竞争死亡时的复发累计发生概率。实线为 Aalen–Johansen 估计,虚线为把死亡当普通删失后的 1-KM;虚线系统性更高。
原因别 Cox 模型回答“在当前仍未发生任何事件者中,目标原因的瞬时率如何变化”;累积发生函数回答“在所有竞争过程共同存在时,目标事件到某时点的现实概率是多少”。二者不是互相替代的同一尺度。
一次事件并不总能概括过程。复发住院可用总时间或间隔时间表示,并需说明事件顺序、终末事件和同一人内相关性;“无病→复发→死亡”则是多状态过程。Aalen–Johansen 可估计状态占有或转移概率,但回归效应需要为每条转移明确模型。
# Andersen–Gill 形式:同一人多行,并对 id 使用聚类稳健方差。
recurrent_fit <- survival::coxph(
survival::Surv(start, stop, event) ~ exposure + cluster(id),
data = recurrent_long
)
# 多状态 Surv:status 是 factor,第一水平表示删失/无转移。
# 初始状态由 istate 指定;真正的多转移过程通常使用 start–stop 数据。
multistate_fit <- survival::survfit(
survival::Surv(start, stop, status) ~ group,
data = multistate_long,
id = id,
istate = initial_state
)代码骨架不是通用处方。Andersen–Gill、事件序次模型、共享 frailty 与多状态模型具有不同的风险集和估计对象;应先画出允许的转移图,再确定每行数据代表的时间区间。
| 设计问题 | 可能后果 | 分析前应做什么 |
|---|---|---|
| 时间零点与分组时点不一致 | 不死时间或选择偏倚 | 对齐资格、分组和随访开始 |
| 结局判定频率因组别不同 | 检测机会偏倚 | 统一随访计划或建模观察过程 |
| 失访与未记录病情相关 | 信息性删失 | 收集原因,做加权或敏感性分析 |
| 基线协变量缺失 | 完整案例选择偏倚、精度下降 | 描述模式并规划多重插补 |
| 治疗在随访中改变 | 暴露错分、时间变化混杂 | 使用时间更新数据并明确因果策略 |
| 竞争事件未区分 | 估计对象混乱 | 区分原因别 hazard 与累积发生概率 |
多重插补模型应包含事件指示、随访信息、重要辅助变量和与缺失相关的变量;不能把删失后的未知事件时间当成普通缺失连续值直接填补。事件数而非总样本量通常更限制模型复杂度,非线性、交互与时间变化效应都需要额外信息支持。
因果解释还需要明确干预、比较策略、目标人群、宽限期、治疗切换、失访处理,以及一致性、可交换性和正值性。基线混杂变量应由时间顺序和因果结构决定,而非按单变量 p 值筛选;治疗后的变量可能是中介或碰撞点。更完整的框架见 因果推断专题。
生存预测也需要公平性审查 较高预测风险可能反映诊断机会、医疗可及性或结构性不平等。部署模型前,应按相关群体比较删失、校准和误差,审查变量含义与潜在伤害,并说明预测将如何影响资源分配。
研究问题:在模拟随机分组队列中,干预如何影响 24 个月内首次研究终点的时间分布?
预先指定的主要绝对估计量是 24 个月 RMST 差;补充报告 12 与 24 个月 KM 生存概率、标准 log-rank 检验,以及允许 12 个月后效应改变的调整 HR。删失按生成机制独立,所有结果仍仅为教学模拟。
standard_s12 <- km_selected_table$生存概率[
km_selected_table$管理策略 == "标准管理" &
km_selected_table$时间月 == 12
]
intervention_s12 <- km_selected_table$生存概率[
km_selected_table$管理策略 == "干预" &
km_selected_table$时间月 == 12
]
standard_s24 <- km_selected_table$生存概率[
km_selected_table$管理策略 == "标准管理" &
km_selected_table$时间月 == 24
]
intervention_s24 <- km_selected_table$生存概率[
km_selected_table$管理策略 == "干预" &
km_selected_table$时间月 == 24
]
case_results <- data.frame(
结果 = c(
"样本与观察终点",
"12 个月生存概率",
"24 个月生存概率",
"24 个月 RMST 差",
"log-rank 检验",
"0 至 ≤12 个月调整 HR",
">12 个月调整 HR"
),
估计或检验 = c(
sprintf("n=%d;事件=%d;右删失=%d", nrow(study_data),
sum(study_data$event), sum(study_data$event == 0)),
sprintf("标准管理 %.1f%%;干预 %.1f%%",
100 * standard_s12, 100 * intervention_s12),
sprintf("标准管理 %.1f%%;干预 %.1f%%",
100 * standard_s24, 100 * intervention_s24),
sprintf("%.2f 月(95%% CI %.2f 至 %.2f)",
rmst_difference, rmst_difference_ci[1], rmst_difference_ci[2]),
sprintf("卡方=%.2f;p=%s", logrank_fit$chisq, format_p(logrank_p)),
sprintf("%.2f(95%% CI %.2f 至 %.2f)",
period_hr_table$HR[1], period_hr_table$`95% CI 下限`[1],
period_hr_table$`95% CI 上限`[1]),
sprintf("%.2f(95%% CI %.2f 至 %.2f)",
period_hr_table$HR[2], period_hr_table$`95% CI 下限`[2],
period_hr_table$`95% CI 上限`[2])
),
check.names = FALSE
)
knitr::kable(case_results, caption = "主队列的预先指定分析结果")| 结果 | 估计或检验 |
|---|---|
| 样本与观察终点 | n=720;事件=575;右删失=145 |
| 12 个月生存概率 | 标准管理 43.7%;干预 64.0% |
| 24 个月生存概率 | 标准管理 21.6%;干预 15.5% |
| 24 个月 RMST 差 | 2.08 月(95% CI 0.92 至 3.23) |
| log-rank 检验 | 卡方=1.78;p=0.182 |
| 0 至 ≤12 个月调整 HR | 0.50(95% CI 0.40 至 0.63) |
| >12 个月调整 HR | 1.83(95% CI 1.39 至 2.39) |
在 720 名模拟参与者中观察到 575 个首次终点,145 人右删失。到 24 个月,干预组的平均无事件时间比标准管理组长 2.08 个月(95% CI:0.92 至 3.23)。然而效应明显随时间改变:前 12 个月调整 HR 为 0.5,之后为 1.83;KM 曲线后期交叉,PH 检验也提供反证。因此不应把随访期单一 HR 或 log-rank p 值作为完整结论。该结果来自已知机制的模拟随机分组数据,不能外推为真实干预效果。
| 错误 | 为什么不对 | 更好的做法 |
|---|---|---|
| 把删失写成“没有事件” | 删失后结局未知 | 报告已知超过的观察时间 |
| 事件 0/1 方向颠倒 | 生存曲线和模型方向完全相反 | 建模前核对频数与原始定义 |
| 只给 KM 曲线,不给风险人数 | 尾部可靠性无法判断 | 同时报风险表与置信区间 |
| log-rank p>0.05 就说曲线相同 | 未拒绝不等于等效,交叉可抵消 | 报效应量并检查时间异质性 |
| 把 HR 当固定时点风险比 | 条件瞬时率不同于累积概率 | 另报固定时点生存或风险 |
cox.zph p>0.05 就证明 PH |
检验可能低效 | 结合图形、风险人数和知识 |
| PH 失败就自动改 AFT | AFT 有独立的时间缩放假设 | 分别诊断并按估计量选模型 |
| 忽略 entry | 把未来入组者放进早期风险集 | 使用 Surv(entry, exit, event) |
| 把“以后治疗”当基线变量 | 引入未来信息与不死时间偏倚 | 使用 start–stop 时间更新数据 |
| 竞争死亡当删失后用 1-KM | 高估现实目标事件概率 | 用 Aalen–Johansen 累积发生函数 |
| 超出风险支持范围外推 | 结果由模型尾部驱动 | 标出观察范围并做敏感性分析 |
| 显著调整 HR 就作因果结论 | 设计、混杂与删失仍可能有偏 | 明确识别条件与目标人群 |
Surv()?Surv(entry, exit, event),其中 entry=4 个月。| 目标 | 公式或代码 | 解释 |
|---|---|---|
| 生存函数 | 到时点 t 仍无事件的概率 | |
| 累积 hazard | hazard 随时间的累积 | |
| KM | 右删失下的非参数生存估计 | |
| Nelson–Aalen | 非参数累积 hazard 估计 | |
| RMST | 截止 τ 的平均无事件时间 | |
| KM 曲线 | survfit(Surv(time, event) ~ group, data=d) |
同时报风险人数 |
| log-rank | survdiff(Surv(time, event) ~ group, data=d) |
检验,不是效应估计 |
| 延迟进入 | Surv(entry, exit, event) |
entry 后才进入风险集 |
| 时间更新 | Surv(start, stop, event) |
每区间使用当时协变量 |
| 竞争风险 | Surv(time, factor_status) |
survfit() 返回 pstate |
后续可学习灵活参数生存模型、样条时间效应、调整后 RMST、逆概率删失加权、半参数 AFT、竞争风险回归、联合纵向–生存模型、复发事件、frailty、多状态回归、动态预测以及内部与外部验证。
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
##
## time zone: America/Edmonton
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 Matrix_1.7-5
## [5] xfun_0.60 lattice_0.22-9 splines_4.6.1 cachem_1.1.0
## [9] knitr_1.51 htmltools_0.5.9 rmarkdown_2.31 lifecycle_1.0.5
## [13] cli_3.6.6 grid_4.6.1 sass_0.4.10 jquerylib_0.1.4
## [17] compiler_4.6.1 tools_4.6.1 evaluate_1.0.5 bslib_0.12.0
## [21] survival_3.8-6 yaml_2.3.12 rlang_1.3.0 jsonlite_2.0.0