Skip to contents

这一步要解决什么问题

这篇教程是 leo.ukb 生存分析部分的第一步。正式进入亚组分析、交互效应或 中介分析之前,我们首先要回答最基础的问题:

这个 cohort 里,exposure 和 incident outcome 之间有没有主关联?

这就是主 Cox 分析的任务。

在真实研究里,把流程拆成几个阶段会更清楚:

  1. 先建立 exposure-outcome 的主关联
  2. 再问这个关联会不会在不同人群里不一样
  3. 再问这个关联有没有可能部分通过某条 mediator 路径传递

所以 Step 1 是后面所有分析的地基。

先从研究问题和变量角色开始

下面继续用 survival::lung 做教学示例,但我们先按真实 cohort 分析的思路 定义好每列变量的角色。

lung_df <- dplyr::transmute(
  survival::lung,
  outcome = as.integer(status == 2),
  outcome_censor = time / 365.25,
  age = age,
  sex = factor(sex, levels = c(1, 2), labels = c("Male", "Female")),
  ecog_group = factor(
    ph.ecog,
    levels = 0:3,
    labels = c("ECOG0", "ECOG1", "ECOG2", "ECOG3")
  )
)

先把变量角色说清楚,而不是直接开始跑模型。

Step 1 教学数据框中的变量角色
变量 类型 Step 1 角色
outcome 二分类事件指示变量 incident outcome 是否发生
outcome_censor 以年为单位的随访时间 incident outcome 随访时间
age 连续 exposure 第一个示例里的主 exposure
sex 二分类协变量 基础人口学调整变量
ecog_group ECOG 体能状态(4 组) ECOG 体能状态调整变量

建模前先检查分析数据

主 Cox 分析要先让人信服,最简单的办法就是在建模前先把 cohort 结构、事件负担 和随访情况看清楚。

dplyr::glimpse(lung_df)
#> Rows: 228
#> Columns: 5
#> $ outcome        <int> 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ outcome_censor <dbl> 0.8377823, 1.2457221, 2.7652293, 0.5749487, 2.4175222, 
#> $ age            <dbl> 74, 68, 56, 57, 60, 74, 68, 71, 53, 61, 57, 68, 68, 60,
#> $ sex            <fct> Male, Male, Male, Male, Male, Male, Female, Female, Mal…
#> $ ecog_group     <fct> ECOG1, ECOG0, ECOG0, ECOG1, ECOG0, ECOG1, ECOG2, ECOG2,
knitr::kable(utils::head(lung_df, 6))
outcome outcome_censor age sex ecog_group
1 0.8377823 74 Male ECOG1
1 1.2457221 68 Male ECOG0
0 2.7652293 56 Male ECOG0
1 0.5749487 57 Male ECOG1
1 2.4175222 60 Male ECOG0
0 2.7980835 74 Male ECOG1
event_summary <- data.frame(
  指标 = c("Participants", "Events", "Event rate"),
  数值 = c(
    nrow(lung_df),
    sum(lung_df$outcome),
    sprintf("%.1f%%", 100 * mean(lung_df$outcome))
  )
)

knitr::kable(
  event_summary,
  caption = "总体事件负担"
)
总体事件负担
指标 数值
Participants 228
Events 165
Event rate 72.4%
knitr::kable(
  dplyr::count(lung_df, sex, outcome),
  caption = "按 sex 分层的事件数"
)
按 sex 分层的事件数
sex outcome n
Male 0 26
Male 1 112
Female 0 37
Female 1 53
followup_summary <- summary(lung_df$outcome_censor)
knitr::kable(
  data.frame(
    统计量 = names(followup_summary),
    数值 = as.numeric(followup_summary)
  ),
  caption = "以年为单位的随访时间概况"
)
以年为单位的随访时间概况
统计量 数值
Min. 0.0136893
1st Qu. 0.4565366
Median 0.6995209
Mean 0.8356809
3rd Qu. 1.0855578
Max. 2.7980835

这些检查能先回答几个最基础的问题:

  • 事件率大概是多少
  • follow-up 的量级是否合理
  • 不同协变量层次里有没有明显过稀的单元

建模前先决定 exposure 的尺度

真正开始拟合之前,先决定 exposure 是按连续变量解释,还是按分类变量解释。 这会直接决定 hazard ratio 的含义。

scale_table <- data.frame(
  Exposure_设定 = c("连续 exposure", "分类 exposure"),
  主要科学问题 = c(
    "每增加 1 个单位,hazard 改变多少?",
    "每个 level 相对于参考组的 hazard 如何?"
  ),
  本教程示例 = c("age", "ecog_group"),
  主要设置 = c("默认", "x_exp_type = 'categorical'")
)

knitr::kable(
  scale_table,
  caption = "主 Cox 模型中 exposure 尺度的选择"
)
主 Cox 模型中 exposure 尺度的选择
Exposure_设定 主要科学问题 本教程示例 主要设置
连续 exposure 每增加 1 个单位,hazard 改变多少? age 默认
分类 exposure 每个 level 相对于参考组的 hazard 如何? ecog_group x_exp_type = ‘categorical’

实践中可以记住一句话:

  • 如果问题是“每增加 1 单位会怎样”,就保留连续形式
  • 如果问题是“某一类和参考组相比如何”,就按分类 exposure 跑

建模前先定义好调整层次

在 cohort 研究里,模型名字最好对应科学上的调整层次,而不是只为了写代码方便。 leo_cox() 接受命名 list,这些名字会直接出现在最终结果表里。

cox_models <- list(
  "Crude" = NULL,
  "Model A" = c("sex"),
  "Model B" = c("sex", "ecog_group")
)

cox_models
#> $Crude
#> NULL
#> 
#> $`Model A`
#> [1] "sex"
#> 
#> $`Model B`
#> [1] "sex"        "ecog_group"

这会让结果天然更像论文表,而不是后面再手工整理一遍。

主 Cox 示例 1:连续 exposure

我们先看 age 和结局之间的主关联。

res_cox <- leo_cox(
  df = lung_df,
  y_out = c("outcome", "outcome_censor"),
  x_exp = "age",
  x_cov = cox_models,
  verbose = FALSE
)

res_cox
#>   Exposure Outcome Case N Control N Person-years      Class Crude HR
#> 1      age outcome    164        63     190.3409 Continuous    1.019
#>   Crude 95% CI Crude P value Model A HR Model A 95% CI Model A P value
#> 1 1.001, 1.038         0.040      1.017   0.999, 1.036           0.061
#>   Model B HR Model B 95% CI Model B P value
#> 1      1.011   0.993, 1.029           0.246

主 Cox 结果应该怎么读

这张表已经很接近论文主表:

  • Case NControl NPerson-years 告诉你用了多少信息
  • Class 告诉你 exposure 是按连续还是分类处理的
  • 每个模型块里直接给 HR95% CIP value

对于连续 exposure,hazard ratio 的含义是:

exposure 每增加 1 个单位,对应的 hazard ratio 是多少

在这里就是年龄每增加 1 岁的 effect。

如果需要 tidy 表用于后续编程,可以设置 simplify = "tidy" 或者 用 simplify = FALSE 配合 leo_cox_format()

res_cox_full <- leo_cox(
  df = lung_df,
  y_out = c("outcome", "outcome_censor"),
  x_exp = "age",
  x_cov = cox_models,
  simplify = FALSE,
  verbose = FALSE
)
age_tidy <- leo_cox_format(res_cox_full, style = "tidy")
age_model_b <- dplyr::filter(age_tidy, Model == "Model B")
age_model_b
#>     Model Exposure Outcome Level Case N Control N Person-years    HR
#> 1 Model B      age outcome  <NA>    164        63     190.3409 1.011
#>         95% CI P value      Class
#> 1 0.993, 1.029   0.246 Continuous

这个教学示例里,Model B 的估计值是 HR = 1.011,意味着在调整 sex 和 ECOG 后,年龄每增加 1 岁,incident hazard 大约增加 1.1%。但它的置信区间跨过了 1, 说明这个调整后的估计仍然不够精确,证据并不强。

这正是主 Cox 结果最标准的阅读顺序:

  • 先看方向和效应大小
  • 再看不确定性
  • 最后再做科学上保守的解释

什么时候切换到 tidy 输出

宽表适合直接读和直接汇报。
如果你接下来还要继续做 dplyr 处理、拼接多组结果或画图,tidy 表会更方便。

dplyr::select(
  age_tidy,
  Model, Exposure, Outcome, HR, `95% CI`, `P value`
)
#>     Model Exposure Outcome    HR       95% CI P value
#> 1   Crude      age outcome 1.019 1.001, 1.038   0.040
#> 2 Model A      age outcome 1.017 0.999, 1.036   0.061
#> 3 Model B      age outcome 1.011 0.993, 1.029   0.246

可以把规则记成一句话:

  • simplify = "wide"(默认)或 simplify = "tidy" 直接拿结果表
  • simplify = FALSE 拿完整对象,可以继续用 leo_cox_format() 或查看拟合结果

也可以设置 p_fmt = "scientific" 来显示精确 p 值,而不是 <0.001

主 Cox 示例 2:分类 exposure

同一套工作流也适用于分类 exposure。

res_cox_cat <- leo_cox(
  df = lung_df,
  y_out = c("outcome", "outcome_censor"),
  x_exp = "ecog_group",
  x_cov = list("Crude" = NULL, "Model A" = c("sex")),
  x_exp_type = "categorical",
  verbose = FALSE
)

res_cox_cat
#>      Exposure Outcome Case N Control N Person-years                  Class
#> 1 ECOG0 (Ref) outcome     37        26   60.6926762 Categorical (4 levels)
#> 2       ECOG1 outcome     82        31   97.2813142 Categorical (4 levels)
#> 3       ECOG2 outcome     44         6   32.0438056 Categorical (4 levels)
#> 4       ECOG3 outcome      1         0    0.3230664 Categorical (4 levels)
#>   Crude HR  Crude 95% CI Crude P value Model A HR Model A 95% CI
#> 1    1.000  1.000, 1.000         1.000      1.000   1.000, 1.000
#> 2    1.446  0.980, 2.134         0.063      1.519   1.028, 2.246
#> 3    2.500  1.610, 3.883        <0.001      2.579   1.660, 4.007
#> 4    9.097 1.218, 67.937         0.031      7.756  1.037, 58.039
#>   Model A P value
#> 1           1.000
#> 2           0.036
#> 3          <0.001
#> 4           0.046

这张表和年龄模型的读法不一样:

  • ECOG0 是参考组
  • 其他每一行都表示某个 level 相对 ECOG0 的比较
  • 所以这里不再是“每增加 1 单位”,而是 “level vs reference”
ecog_tidy <- leo_cox(
  df = lung_df,
  y_out = c("outcome", "outcome_censor"),
  x_exp = "ecog_group",
  x_cov = list("Crude" = NULL, "Model A" = c("sex")),
  x_exp_type = "categorical",
  simplify = "tidy",
  verbose = FALSE
)
dplyr::select(
  ecog_tidy,
  Model, Exposure, Level, HR, `95% CI`, `P value`
)
#>     Model    Exposure Level    HR        95% CI P value
#> 1   Crude ECOG0 (Ref) ECOG0 1.000  1.000, 1.000   1.000
#> 2   Crude       ECOG1 ECOG1 1.446  0.980, 2.134   0.063
#> 3   Crude       ECOG2 ECOG2 2.500  1.610, 3.883  <0.001
#> 4   Crude       ECOG3 ECOG3 9.097 1.218, 67.937   0.031
#> 5 Model A ECOG0 (Ref) ECOG0 1.000  1.000, 1.000   1.000
#> 6 Model A       ECOG1 ECOG1 1.519  1.028, 2.246   0.036
#> 7 Model A       ECOG2 ECOG2 2.579  1.660, 4.007  <0.001
#> 8 Model A       ECOG3 ECOG3 7.756 1.037, 58.039   0.046

在这个示例里,Model A 提示较差的 ECOG 体能状态与更高的 incident hazard 相关:

  • ECOG1 vs ECOG0: HR = 1.519
  • ECOG2 vs ECOG0: HR = 2.579
  • ECOG3 vs ECOG0: HR = 7.756

但最后一行也提醒我们一个真实研究里必须警惕的问题:ECOG3 这一层在这个小示例中只有 1 个事件,因此它的置信区间极宽。稀疏单元不是表格上的小问题,而是实质性的解释风险。

Step 1 常见结果模式应该怎么解读

主 Cox 结果通常按下面这个顺序理解:

  1. 如果 HR 明显偏离 1,且置信区间不跨 1,那么主关联方向和统计证据都比较清楚
  2. 如果 HR 偏离 1,但置信区间跨 1,说明数据提示可能存在关联,但估计还不够精确
  3. 如果点估计接近 1,通常提示效应量可能较小;但统计证据强弱仍要结合置信区间精度、样本量和具体研究背景一起判断
  4. 对分类 exposure,一定要同时看效应量和每个 level 的样本/事件负担;稀疏组很容易产生看起来很大的不稳定 HR

所以 Step 1 不是“把 Cox 跑出来”就结束,而是要学会分辨:

  • 信号
  • 不确定性
  • 稀疏数据带来的假象

论文里 Step 1 结果应该怎么写

对于连续 exposure,可以写成:

在 fully adjusted model 中,年龄每增加 1 岁,incident hazard 估计增加约 1.1% (HR = 1.011, 95% CI 0.993 to 1.029),但其置信区间包含 1。

对于分类 exposure,可以写成:

ECOG0 相比,较差的 ECOG 体能状态与更高的 incident hazard 相关, 其中 ECOG2 vs ECOG0 的对比最强(HR = 2.579, 95% CI 1.660 to 4.007), 该估计在 sex 调整后仍然存在。

这类写法的共同点是:

  • 先写清 exposure contrast
  • HR95% CI 和模型层次
  • 结论保持保守,不过度外推

进入 Step 2 之前应该带走什么

做完这一步以后,你应该已经清楚:

  1. 生存分析数据框应该如何组织
  2. 为什么建模前要先看事件负担和 follow-up
  3. exposure 什么时候保持连续、什么时候转成分类
  4. 协变量调整层次为什么要先定义
  5. 主 Cox 结果怎样翻译成更接近论文语言的表达

Step 2 接下来问的就不是“有没有主关联”,而是:

这个 exposure 的效应,会不会在不同人群里不一样?又应该在哪个尺度上解释?

那就是亚组分析和交互效应的范围了。