@bgicli/bgicli 2.2.7 → 2.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ ---
2
+ id: survival-analysis-clinical
3
+ name: Clinical Survival & Outcome Analysis
4
+ category: clinical
5
+ short-description: Kaplan-Meier survival curves, log-rank tests, Cox proportional hazards regression, and competing risks analysis for clinical outcome data.
6
+ detailed-description: Complete survival analysis workflow covering Kaplan-Meier estimation, log-rank/Wilcoxon tests, univariate and multivariate Cox regression, proportional hazards assumption diagnostics, time-dependent ROC, and competing risks (Fine-Gray model). Handles right-censored data, stratified analyses, and produces publication-ready figures. Use when you have time-to-event data (OS, PFS, DFS, RFS) with censoring indicators.
7
+ starting-prompt: Perform survival analysis on my clinical outcome data with Kaplan-Meier curves and Cox regression.
8
+ ---
9
+
10
+ # 临床生存分析工作流
11
+
12
+ Kaplan-Meier 生存曲线 + Cox 回归 + 竞争风险模型,适用于 OS/PFS/DFS 等临床终点分析。
13
+
14
+ ## 适用场景
15
+
16
+ - ✅ **时间-事件数据**:总生存期(OS)、无进展生存期(PFS)、无病生存期(DFS)
17
+ - ✅ **右删失数据**(right-censored):患者失访或研究结束时未发生事件
18
+ - ✅ **分组比较**:治疗组 vs 对照组、高表达 vs 低表达、突变 vs 野生型
19
+ - ✅ **多变量校正**:年龄、分期、治疗方案等协变量的 Cox 回归
20
+ - ✅ **竞争风险**:存在多种终点事件时(如死亡 vs 复发)
21
+
22
+ **不适用:**
23
+ - ❌ 无删失信息的数据(需要删失指示变量)
24
+ - ❌ 重复事件数据 → 使用 Andersen-Gill 模型
25
+
26
+ ---
27
+
28
+ ## 快速开始(示例数据)
29
+
30
+ ```r
31
+ # 安装依赖
32
+ options(repos = c(CRAN = "https://cloud.r-project.org"))
33
+ install.packages(c("survival", "survminer", "ggplot2", "dplyr", "broom",
34
+ "timeROC", "cmprsk", "forestplot"))
35
+
36
+ # 加载示例数据(TCGA LUAD 肺腺癌,228例)
37
+ source("scripts/load_example_data.R")
38
+ data <- load_tcga_luad_example()
39
+ # data$time : 生存时间(月)
40
+ # data$status : 事件指示(1=死亡, 0=删失)
41
+ # data$group : 分组变量(High/Low 表达)
42
+ # data$age, data$stage, data$gender : 协变量
43
+
44
+ # 运行完整分析
45
+ source("scripts/full_workflow.R")
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 数据格式要求
51
+
52
+ ```r
53
+ # 最小必需列:
54
+ head(data)
55
+ # patient_id time status group
56
+ # TCGA-001 24.3 1 High
57
+ # TCGA-002 48.0 0 Low # 0 = 删失(censored)
58
+ # TCGA-003 12.1 1 High
59
+
60
+ # 检查数据完整性(分析前必做)
61
+ stopifnot(
62
+ all(data$status %in% c(0, 1)), # 状态只能是 0 或 1
63
+ all(data$time > 0), # 时间必须为正数
64
+ !any(is.na(data$time)), # 时间不能有缺失
65
+ !any(is.na(data$status)) # 状态不能有缺失
66
+ )
67
+ cat(sprintf("样本数: %d | 事件数: %d (%.1f%%) | 中位随访: %.1f 月\n",
68
+ nrow(data), sum(data$status),
69
+ mean(data$status) * 100,
70
+ median(data$time)))
71
+ ```
72
+
73
+ ---
74
+
75
+ ## 第一步:Kaplan-Meier 生存曲线
76
+
77
+ ```r
78
+ library(survival)
79
+ library(survminer)
80
+
81
+ # 构建生存对象
82
+ surv_obj <- Surv(time = data$time, event = data$status)
83
+
84
+ # KM 拟合(按分组)
85
+ km_fit <- survfit(surv_obj ~ group, data = data)
86
+
87
+ # 打印中位生存时间 + 95% CI
88
+ print(km_fit)
89
+ summary(km_fit)$table # 各组中位生存时间
90
+
91
+ # 绘制 KM 曲线(出版级)
92
+ km_plot <- ggsurvplot(
93
+ km_fit,
94
+ data = data,
95
+ pval = TRUE, # 显示 log-rank p 值
96
+ pval.method = TRUE, # 显示检验方法
97
+ conf.int = TRUE, # 95% 置信区间
98
+ risk.table = TRUE, # 风险表(at-risk numbers)
99
+ risk.table.height = 0.25,
100
+ xlab = "时间(月)",
101
+ ylab = "生存概率",
102
+ legend.labs = levels(factor(data$group)),
103
+ palette = c("#E64B35", "#4DBBD5"), # 红蓝配色
104
+ ggtheme = theme_bw(base_size = 14),
105
+ surv.median.line = "hv", # 标注中位生存线
106
+ break.time.by = 12 # X 轴每 12 月一个刻度
107
+ )
108
+ print(km_plot)
109
+ ggsave("km_curve.pdf", plot = print(km_plot), width = 8, height = 7)
110
+ cat("✓ KM 曲线已保存: km_curve.pdf\n")
111
+ ```
112
+
113
+ ### Log-rank 检验
114
+
115
+ ```r
116
+ # 标准 log-rank 检验(对晚期差异更敏感)
117
+ logrank_test <- survdiff(surv_obj ~ group, data = data)
118
+ p_logrank <- 1 - pchisq(logrank_test$chisq, df = length(levels(factor(data$group))) - 1)
119
+ cat(sprintf("Log-rank p 值: %.4f\n", p_logrank))
120
+
121
+ # Wilcoxon 检验(对早期差异更敏感,rho=1)
122
+ wilcox_test <- survdiff(surv_obj ~ group, data = data, rho = 1)
123
+ p_wilcox <- 1 - pchisq(wilcox_test$chisq, df = 1)
124
+ cat(sprintf("Wilcoxon p 值: %.4f\n", p_wilcox))
125
+ ```
126
+
127
+ ---
128
+
129
+ ## 第二步:单变量 Cox 回归
130
+
131
+ ```r
132
+ library(broom)
133
+
134
+ # 单变量 Cox(逐个变量)
135
+ covariates <- c("group", "age", "stage", "gender")
136
+ uni_results <- lapply(covariates, function(var) {
137
+ formula <- as.formula(paste("surv_obj ~", var))
138
+ fit <- coxph(formula, data = data)
139
+ tidy(fit, exponentiate = TRUE, conf.int = TRUE) %>%
140
+ mutate(variable = var)
141
+ })
142
+ uni_df <- do.call(rbind, uni_results)
143
+
144
+ # 打印结果(HR + 95%CI + p值)
145
+ cat("\n=== 单变量 Cox 回归结果 ===\n")
146
+ print(uni_df[, c("variable", "term", "estimate", "conf.low", "conf.high", "p.value")])
147
+
148
+ # 筛选 p < 0.05 的变量进入多变量模型
149
+ sig_vars <- uni_df$term[uni_df$p.value < 0.05]
150
+ cat(sprintf("\n显著变量 (p<0.05): %s\n", paste(sig_vars, collapse = ", ")))
151
+ ```
152
+
153
+ ---
154
+
155
+ ## 第三步:多变量 Cox 回归
156
+
157
+ ```r
158
+ # 多变量 Cox(纳入单变量显著变量)
159
+ multi_formula <- as.formula(paste("surv_obj ~", paste(sig_vars, collapse = " + ")))
160
+ cox_multi <- coxph(multi_formula, data = data)
161
+
162
+ # 结果摘要
163
+ cat("\n=== 多变量 Cox 回归结果 ===\n")
164
+ print(summary(cox_multi))
165
+
166
+ # 提取 HR 表格
167
+ multi_df <- tidy(cox_multi, exponentiate = TRUE, conf.int = TRUE)
168
+ cat("\nHazard Ratio 汇总:\n")
169
+ print(multi_df[, c("term", "estimate", "conf.low", "conf.high", "p.value")])
170
+
171
+ # 森林图
172
+ library(forestplot)
173
+ source("scripts/plot_forest.R")
174
+ plot_cox_forest(multi_df, output = "cox_forest.pdf")
175
+ cat("✓ 森林图已保存: cox_forest.pdf\n")
176
+ ```
177
+
178
+ ### 比例风险假设检验(PH assumption)
179
+
180
+ ```r
181
+ # Schoenfeld 残差检验(p > 0.05 表示满足 PH 假设)
182
+ ph_test <- cox.zph(cox_multi)
183
+ print(ph_test)
184
+
185
+ if (any(ph_test$table[, "p"] < 0.05)) {
186
+ cat("⚠ 以下变量违反比例风险假设,考虑分层 Cox 或时间交互项:\n")
187
+ print(ph_test$table[ph_test$table[, "p"] < 0.05, ])
188
+ } else {
189
+ cat("✓ 所有变量满足比例风险假设\n")
190
+ }
191
+
192
+ # 绘制 Schoenfeld 残差图
193
+ ggcoxzph(ph_test)
194
+ ```
195
+
196
+ ---
197
+
198
+ ## 第四步:最优截断值(连续变量分组)
199
+
200
+ ```r
201
+ # 当分组变量是连续值(如基因表达量)时,寻找最优截断点
202
+ library(survminer)
203
+
204
+ # 方法1:surv_cutpoint(最大化 log-rank 统计量)
205
+ cut_result <- surv_cutpoint(
206
+ data,
207
+ time = "time",
208
+ event = "status",
209
+ variables = "expression_value" # 替换为你的连续变量名
210
+ )
211
+ print(summary(cut_result))
212
+ optimal_cutoff <- cut_result$cutpoint$cutpoint
213
+ cat(sprintf("最优截断值: %.3f\n", optimal_cutoff))
214
+
215
+ # 按截断值分组
216
+ data$group_cut <- ifelse(data$expression_value >= optimal_cutoff, "High", "Low")
217
+
218
+ # 重新绘制 KM 曲线
219
+ km_fit2 <- survfit(Surv(time, status) ~ group_cut, data = data)
220
+ ggsurvplot(km_fit2, data = data, pval = TRUE, risk.table = TRUE)
221
+ ```
222
+
223
+ ---
224
+
225
+ ## 第五步:竞争风险分析(Fine-Gray 模型)
226
+
227
+ ```r
228
+ # 当存在竞争事件时(如:关注复发,但患者可能先死亡)
229
+ # status: 0=删失, 1=目标事件(复发), 2=竞争事件(死亡)
230
+ library(cmprsk)
231
+
232
+ # 累积发生函数(CIF)
233
+ cif_fit <- cuminc(
234
+ ftime = data$time,
235
+ fstatus = data$status_competing, # 0/1/2
236
+ group = data$group
237
+ )
238
+
239
+ # 绘制 CIF 曲线
240
+ plot(cif_fit, col = c("#E64B35", "#4DBBD5", "#00A087", "#3C5488"),
241
+ xlab = "时间(月)", ylab = "累积发生率",
242
+ main = "竞争风险累积发生函数")
243
+
244
+ # Fine-Gray 回归(仅针对目标事件)
245
+ fg_model <- crr(
246
+ ftime = data$time,
247
+ fstatus = data$status_competing,
248
+ cov1 = model.matrix(~ group + age + stage, data = data)[, -1]
249
+ )
250
+ summary(fg_model)
251
+ ```
252
+
253
+ ---
254
+
255
+ ## 第六步:时间依赖 ROC(预测性能评估)
256
+
257
+ ```r
258
+ library(timeROC)
259
+
260
+ # 评估 Cox 模型在不同时间点的预测性能
261
+ risk_score <- predict(cox_multi, type = "risk")
262
+
263
+ roc_result <- timeROC(
264
+ T = data$time,
265
+ delta = data$status,
266
+ marker = risk_score,
267
+ cause = 1,
268
+ times = c(12, 24, 36, 60), # 1年、2年、3年、5年 AUC
269
+ iid = TRUE
270
+ )
271
+
272
+ cat("\n=== 时间依赖 AUC ===\n")
273
+ print(roc_result$AUC)
274
+
275
+ # 绘制 ROC 曲线
276
+ plot(roc_result, time = 36, title = "3年生存预测 ROC 曲线")
277
+ ```
278
+
279
+ ---
280
+
281
+ ## 结果解读指南
282
+
283
+ | 指标 | 含义 | 注意事项 |
284
+ |------|------|---------|
285
+ | **HR > 1** | 风险增加(预后差) | 需同时看 95% CI 和 p 值 |
286
+ | **HR < 1** | 风险降低(保护因素) | HR=0.5 表示风险降低 50% |
287
+ | **p < 0.05** | 统计显著 | 多重比较时需 FDR 校正 |
288
+ | **中位生存时间** | 50% 患者存活的时间 | 若曲线未降至 0.5 则无法估计 |
289
+ | **Log-rank p** | 两组生存曲线是否有差异 | 对晚期差异更敏感 |
290
+ | **C-index** | Cox 模型区分度(0.5=随机,1=完美) | >0.7 认为有较好预测性能 |
291
+
292
+ ---
293
+
294
+ ## 常见错误与解决方案
295
+
296
+ **错误1:`Error in Surv(): time must be positive`**
297
+ ```r
298
+ # 检查并移除时间为 0 或负数的行
299
+ data <- data[data$time > 0, ]
300
+ ```
301
+
302
+ **错误2:KM 曲线置信区间异常宽**
303
+ ```r
304
+ # 样本量不足,考虑合并分组或报告时注明样本量限制
305
+ # 检查各组样本量
306
+ table(data$group)
307
+ ```
308
+
309
+ **错误3:Cox 模型不收敛**
310
+ ```r
311
+ # 可能存在完全分离(某变量完全预测事件)
312
+ # 检查各协变量与事件的交叉表
313
+ table(data$group, data$status)
314
+ # 考虑 Firth 惩罚 Cox 回归
315
+ library(coxphf)
316
+ cox_firth <- coxphf(surv_obj ~ group + age, data = data)
317
+ ```
318
+
319
+ **错误4:违反比例风险假设**
320
+ ```r
321
+ # 方案1:分层 Cox(按违反变量分层)
322
+ cox_strat <- coxph(surv_obj ~ group + strata(stage), data = data)
323
+
324
+ # 方案2:加入时间交互项
325
+ cox_time <- coxph(surv_obj ~ group + tt(group), data = data,
326
+ tt = function(x, t, ...) x * log(t))
327
+ ```
328
+
329
+ ---
330
+
331
+ ## 输出文件清单
332
+
333
+ | 文件 | 内容 |
334
+ |------|------|
335
+ | `km_curve.pdf` | Kaplan-Meier 生存曲线(含风险表) |
336
+ | `cox_forest.pdf` | Cox 回归森林图(HR + 95%CI) |
337
+ | `cox_results.csv` | 多变量 Cox 回归完整结果表 |
338
+ | `ph_test.txt` | 比例风险假设检验结果 |
339
+ | `roc_auc.csv` | 时间依赖 AUC 表格 |
340
+
341
+ ---
342
+
343
+ ## 参考文献
344
+
345
+ - Kaplan EL, Meier P. (1958) Nonparametric estimation from incomplete observations. *JASA*.
346
+ - Cox DR. (1972) Regression models and life-tables. *JRSS-B*.
347
+ - Fine JP, Gray RJ. (1999) A proportional hazards model for the subdistribution of a competing risk. *JASA*.
348
+ - Therneau TM, Grambsch PM. (2000) *Modeling Survival Data: Extending the Cox Model*. Springer.
@@ -0,0 +1,95 @@
1
+ # full_workflow.R
2
+ # End-to-end survival analysis: KM + Cox + PH check + Forest plot.
3
+ # Assumes `data` is already loaded (run load_example_data.R first).
4
+
5
+ library(survival)
6
+ library(survminer)
7
+ library(dplyr)
8
+ library(broom)
9
+
10
+ cat("=== 生存分析完整流程 ===\n\n")
11
+
12
+ # ── 0. Data validation ────────────────────────────────────────────────────────
13
+ stopifnot(
14
+ "time" %in% names(data),
15
+ "status" %in% names(data),
16
+ "group" %in% names(data),
17
+ all(data$status %in% c(0, 1)),
18
+ all(data$time > 0)
19
+ )
20
+ cat(sprintf("✓ 数据验证通过: %d 例 | 事件: %d (%.1f%%) | 中位随访: %.1f 月\n\n",
21
+ nrow(data), sum(data$status),
22
+ mean(data$status) * 100, median(data$time)))
23
+
24
+ # ── 1. Kaplan-Meier ───────────────────────────────────────────────────────────
25
+ surv_obj <- Surv(time = data$time, event = data$status)
26
+ km_fit <- survfit(surv_obj ~ group, data = data)
27
+
28
+ cat("--- Kaplan-Meier 中位生存时间 ---\n")
29
+ print(summary(km_fit)$table[, c("median", "0.95LCL", "0.95UCL")])
30
+
31
+ logrank <- survdiff(surv_obj ~ group, data = data)
32
+ p_lr <- 1 - pchisq(logrank$chisq, df = length(levels(factor(data$group))) - 1)
33
+ cat(sprintf("\nLog-rank p 值: %.4f %s\n\n", p_lr,
34
+ ifelse(p_lr < 0.05, "✓ 显著", "(不显著)")))
35
+
36
+ km_plot <- ggsurvplot(
37
+ km_fit, data = data,
38
+ pval = TRUE, conf.int = TRUE, risk.table = TRUE,
39
+ risk.table.height = 0.25,
40
+ xlab = "时间(月)", ylab = "生存概率",
41
+ palette = c("#E64B35", "#4DBBD5"),
42
+ ggtheme = theme_bw(base_size = 13),
43
+ surv.median.line = "hv",
44
+ break.time.by = 12
45
+ )
46
+ ggsave("km_curve.pdf", plot = print(km_plot), width = 8, height = 7)
47
+ cat("✓ KM 曲线已保存: km_curve.pdf\n\n")
48
+
49
+ # ── 2. Univariate Cox ─────────────────────────────────────────────────────────
50
+ covariates <- intersect(c("group", "age", "stage", "gender"), names(data))
51
+ cat("--- 单变量 Cox 回归 ---\n")
52
+ uni_results <- lapply(covariates, function(var) {
53
+ fit <- coxph(as.formula(paste("surv_obj ~", var)), data = data)
54
+ tidy(fit, exponentiate = TRUE, conf.int = TRUE) %>% mutate(variable = var)
55
+ })
56
+ uni_df <- do.call(rbind, uni_results)
57
+ print(uni_df[, c("term", "estimate", "conf.low", "conf.high", "p.value")])
58
+
59
+ sig_vars <- unique(uni_df$term[uni_df$p.value < 0.05])
60
+ cat(sprintf("\n显著变量 (p<0.05): %s\n\n",
61
+ if (length(sig_vars) > 0) paste(sig_vars, collapse = ", ") else "无"))
62
+
63
+ # ── 3. Multivariate Cox ───────────────────────────────────────────────────────
64
+ if (length(sig_vars) >= 2) {
65
+ cat("--- 多变量 Cox 回归 ---\n")
66
+ multi_formula <- as.formula(paste("surv_obj ~", paste(sig_vars, collapse = " + ")))
67
+ cox_multi <- coxph(multi_formula, data = data)
68
+ print(summary(cox_multi))
69
+
70
+ multi_df <- tidy(cox_multi, exponentiate = TRUE, conf.int = TRUE)
71
+ write.csv(multi_df, "cox_results.csv", row.names = FALSE)
72
+ cat("✓ Cox 结果已保存: cox_results.csv\n")
73
+
74
+ # Forest plot
75
+ source("scripts/plot_forest.R")
76
+ plot_cox_forest(multi_df, output = "cox_forest.pdf")
77
+
78
+ # PH assumption check
79
+ cat("\n--- 比例风险假设检验 ---\n")
80
+ ph_test <- cox.zph(cox_multi)
81
+ print(ph_test)
82
+ sink("ph_test.txt"); print(ph_test); sink()
83
+ cat("✓ PH 检验结果已保存: ph_test.txt\n")
84
+
85
+ if (any(ph_test$table[, "p"] < 0.05)) {
86
+ cat("⚠ 部分变量违反 PH 假设,建议使用分层 Cox 或时间交互项\n")
87
+ } else {
88
+ cat("✓ 所有变量满足比例风险假设\n")
89
+ }
90
+ } else {
91
+ cat("⚠ 显著变量不足 2 个,跳过多变量 Cox 回归\n")
92
+ }
93
+
94
+ cat("\n=== 分析完成 ===\n")
95
+ cat("输出文件: km_curve.pdf, cox_forest.pdf, cox_results.csv, ph_test.txt\n")
@@ -0,0 +1,65 @@
1
+ # load_example_data.R
2
+ # Loads a simulated TCGA-LUAD-style survival dataset for testing the workflow.
3
+ # No internet connection required — data is generated deterministically.
4
+
5
+ load_tcga_luad_example <- function(n = 228, seed = 42) {
6
+ set.seed(seed)
7
+
8
+ # Simulate clinical covariates
9
+ age <- round(rnorm(n, mean = 63, sd = 10))
10
+ gender <- sample(c("Male", "Female"), n, replace = TRUE, prob = c(0.55, 0.45))
11
+ stage <- sample(c("I", "II", "III", "IV"), n, replace = TRUE,
12
+ prob = c(0.30, 0.25, 0.25, 0.20))
13
+
14
+ # Simulate gene expression (continuous, used for cutpoint demo)
15
+ expression_value <- rnorm(n, mean = 5, sd = 2)
16
+
17
+ # Assign group based on expression (High/Low)
18
+ group <- ifelse(expression_value >= median(expression_value), "High", "Low")
19
+
20
+ # Simulate survival times with group effect (High = worse prognosis)
21
+ lambda_high <- 0.035
22
+ lambda_low <- 0.020
23
+ lambda <- ifelse(group == "High", lambda_high, lambda_low)
24
+
25
+ # Add stage effect
26
+ stage_mult <- c("I" = 0.6, "II" = 0.9, "III" = 1.2, "IV" = 1.8)
27
+ lambda <- lambda * stage_mult[stage]
28
+
29
+ # Exponential survival times
30
+ true_time <- rexp(n, rate = lambda)
31
+
32
+ # Administrative censoring at 60 months
33
+ censor_time <- runif(n, min = 6, max = 60)
34
+ time <- pmin(true_time, censor_time)
35
+ status <- as.integer(true_time <= censor_time)
36
+
37
+ # Competing risks version (0=censored, 1=death from cancer, 2=other death)
38
+ status_competing <- status
39
+ other_death_idx <- which(status == 1 & runif(sum(status == 1)) < 0.15)
40
+ status_competing[other_death_idx] <- 2
41
+
42
+ data <- data.frame(
43
+ patient_id = paste0("TCGA-", sprintf("%03d", seq_len(n))),
44
+ time = round(time, 1),
45
+ status = status,
46
+ status_competing = status_competing,
47
+ group = group,
48
+ expression_value = round(expression_value, 3),
49
+ age = age,
50
+ gender = gender,
51
+ stage = stage,
52
+ stringsAsFactors = FALSE
53
+ )
54
+
55
+ cat(sprintf(
56
+ "✓ 示例数据已加载: %d 例 | 事件数: %d (%.1f%%) | 中位随访: %.1f 月\n",
57
+ nrow(data), sum(data$status),
58
+ mean(data$status) * 100,
59
+ median(data$time)
60
+ ))
61
+ cat(sprintf(" 分组: High=%d, Low=%d\n",
62
+ sum(data$group == "High"), sum(data$group == "Low")))
63
+
64
+ return(data)
65
+ }
@@ -0,0 +1,46 @@
1
+ # plot_forest.R
2
+ # Generates a publication-ready Cox regression forest plot.
3
+
4
+ plot_cox_forest <- function(cox_df, output = "cox_forest.pdf",
5
+ title = "多变量 Cox 回归森林图") {
6
+ library(ggplot2)
7
+
8
+ # Clean up term names for display
9
+ cox_df$label <- gsub("stage", "分期: ", cox_df$term)
10
+ cox_df$label <- gsub("gender", "性别: ", cox_df$label)
11
+ cox_df$label <- gsub("group", "分组: ", cox_df$label)
12
+ cox_df$label <- gsub("age", "年龄", cox_df$label)
13
+
14
+ # Significance stars
15
+ cox_df$sig <- ifelse(cox_df$p.value < 0.001, "***",
16
+ ifelse(cox_df$p.value < 0.01, "**",
17
+ ifelse(cox_df$p.value < 0.05, "*", "")))
18
+
19
+ cox_df$label_full <- paste0(cox_df$label, " ", cox_df$sig)
20
+
21
+ p <- ggplot(cox_df, aes(x = estimate, y = reorder(label_full, estimate))) +
22
+ geom_vline(xintercept = 1, linetype = "dashed", color = "grey50", linewidth = 0.5) +
23
+ geom_errorbarh(aes(xmin = conf.low, xmax = conf.high),
24
+ height = 0.2, color = "grey40", linewidth = 0.7) +
25
+ geom_point(aes(color = p.value < 0.05), size = 3) +
26
+ scale_color_manual(values = c("TRUE" = "#E64B35", "FALSE" = "#4DBBD5"),
27
+ labels = c("TRUE" = "p < 0.05", "FALSE" = "p ≥ 0.05"),
28
+ name = "显著性") +
29
+ scale_x_log10() +
30
+ labs(
31
+ title = title,
32
+ x = "风险比 (HR, 95% CI)",
33
+ y = NULL,
34
+ caption = "* p<0.05 ** p<0.01 *** p<0.001"
35
+ ) +
36
+ theme_bw(base_size = 13) +
37
+ theme(
38
+ panel.grid.minor = element_blank(),
39
+ plot.title = element_text(face = "bold"),
40
+ legend.position = "bottom"
41
+ )
42
+
43
+ ggsave(output, plot = p, width = 7, height = max(3, nrow(cox_df) * 0.5 + 2))
44
+ cat(sprintf("✓ 森林图已保存: %s\n", output))
45
+ return(p)
46
+ }