← 返回 Tessera
误差棒4 张图模拟亚组分析(11 行:总体 + 3 个亚组共 7 层)2026-08-04

forest

一堆亚组或模型各自的效应量,哪些真的偏离了无效线?

需要的输入
每行一个效应量 —— 点估计 + 置信区间上下限,外加要一起显示的文本列
示例数据
模拟亚组分析(11 行:总体 + 3 个亚组共 7 层)
依赖
forestploter · grid · ukbflow
配色
未命名#B2182B #4575B4 #F0F0F0
成图预览另有 3 张在配方里
forest

什么时候用它

一次要看很多个效应量的时候:十个亚组各自的 OR、三个模型对同一个暴露的估计、二十项研究的 meta 分析。每行一个估计,一条线一个区间,竖着排开跟同一条无效线比。

只有一两个效应量就别用了——两行的森林图不如把数字直接写进正文。

怎么读

  1. 先找无效线。 比值型指标(OR / HR / RR)在 1,差值型(beta / MD)在 0。
  2. 看区间碰没碰到它。 碰到了就是"这份数据没能排除无效",不是"证明了无效"
  3. 看区间的宽度。 宽是样本少或事件少,跟效应大小无关。一条又宽又偏的线,说的是"不知道",不是"很强"。
  4. 别横向比亚组之间的显著性。 A 组显著、B 组不显著,不等于两组效应不同——那要看交互作用检验,森林图上没有。

输入的形状

这是这张图唯一麻烦的地方。画的部分反而是机械的。

forestploter::forest() 要的不是一张表,而是拆开的四样东西

要什么 说明
data 只放文本列的 data.frame,逐列变成表格里的文字
占位列 data 里必须有一列是全空格,CI 图就画在它的位置;空格数量决定 CI 面板的宽度
ci_column 占位列是第几列
est / lower / upper 三个独立的数值向量,按行和 data 对齐

还有两样是 forest() 不管、得你自己造的:OR (95% CI) 那一列文本sprintf 拼),以及 p 值的两个版本——数值的用来判断要不要加粗,字符串的用来显示(带 <0.001 截断)。

plot_forest() 把后面这些全接管了:你只给文本列和三个向量,它自动插入占位列和 CI 文本列。所以它的 dataforest()data 少两列,ci_column 指的也是插入之前的位置。这是两者最容易混的地方。

常见陷阱

占位列的空格数决定 CI 面板宽度。 它不是随便写的 strrep(" ", 20)——数字改了,图的比例就变了。行数变化时最容易忘掉调它,结果 CI 被挤成一小段。

缩进要用不断行空格   普通空格会被 grid 的文本渲染吃掉,亚组的层级就没了。plot_forest()indent 参数内部用的就是它。

est 里的 NA 是分组标题行。 那一行不画 CI,但 data 里必须有这一行——三个数值向量和 data 的长度要严格一致。

不给 ticks_at 会得到 1.125 这种刻度。 默认是在 xlim 上五等分,c(0.5, 3) 就成了 0.5 / 1.125 / 1.75 / 2.375 / 3。无效线 1 甚至不在刻度上。

不给 header 会把列名当表头。 占位列会顶着 gap_ci、p 值列顶着 p_value 直接进图。

比值型指标该用对数轴。 线性轴上 OR = 0.5 和 OR = 2 是一对等效的相反效应,看起来却一个离 1 很近、一个很远。要对数轴得用 forest(x_trans = "log")——plot_forest() 没有暴露这个参数,这是它目前做不到的事。

画布小了会静默裁掉,别拍脑袋设尺寸。 森林图是个 gtable,行高列宽是按毫米写死的plot_forest() 自动值:顶部 8 mm、表头 12 mm、每个数据行 10 mm、底部 15 mm)。画布比它小,超出的部分直接被切掉——不报错、不警告,页面上就是一张顶线贴边、第一行就是数据的图,除非你数得出表头应该在那儿,否则根本看不出少了东西。反过来画布太大就是一圈留白。

单位统一用厘米。 这一摊里同时躺着三种单位:gtable 内部是毫米plot_forest(save_width / save_height)厘米,而 R 图形设备默认按英寸开。三种混着用,脑子里换算一次就错一次。既然 plot_forest 的存图参数是厘米,画布也一律用厘米,然后取整数——22 × 20 这种,不要 7.68 × 6.50

尺寸不用猜,量得出来:

#| eval: false
p <- plot_forest(...)          # 或 forest(...)
grid::convertWidth(sum(p$widths),   "cm", valueOnly = TRUE)   # 自然宽度
grid::convertHeight(sum(p$heights), "cm", valueOnly = TRUE)   # 自然高度

量出来之后往上取整、再多给两三厘米当上下留白,这是常规做法:宁可四周空一圈,也不要贴着边——贴边一旦差个一两毫米就是截断,而截断是看不出来的。行数变了就重量一次。

配色

三个色,各管一件事:#B2182B 显著、#4575B4 不显著、#F0F0F0 斑马底纹。

按显著性给 CI 上色是有争议的——它把一个连续量(p 值)压成了两档,而 p = 0.049 和 p = 0.051 之间没有实质区别。用它的理由只有一个:图上行数多到一眼扫不完时,颜色帮读者先定位。行数少就别上色,黑色一种更干净。

斑马底纹的作用是横向对齐——行多的时候,眼睛从最左边的标签扫到最右边的 p 值容易串行。

配方

方法绘图系统什么时候选它
Aukbflow::plot_forest()R · forestploter 封装常规亚组图 / 多模型对比,占位列、CI 文本、p 值格式都不用管
Bforestploter::forest()R · grid要对数轴、多列 CI、非常规版式 —— 封装没暴露的那些

数据

亚组分析的典型形状:总体一行,然后每个亚组一个标题行加若干水平行。标题行的 estNA

library(forestploter)
library(grid)

sg <- data.frame(
  Subgroup = c("Overall",
               "Age", "<60", ">=60",
               "Sex", "Male", "Female",
               "BMI", "<25", "25-30", ">=30"),
  `No. of patients` = c("4,521", "", "2,140", "2,381", "", "2,260", "2,261",
                        "", "1,502", "1,808", "1,211"),
  p_value = c(0.003, NA, 0.012, 0.041, NA, 0.008, 0.19,
              NA, 0.44, 0.021, 0.0004),
  check.names = FALSE
)

est   <- c(1.42, NA, 1.31, 1.55, NA, 1.61, 1.18, NA, 1.09, 1.38, 1.87)
lower <- c(1.13, NA, 1.06, 1.02, NA, 1.13, 0.92, NA, 0.88, 1.05, 1.32)
upper <- c(1.78, NA, 1.62, 2.36, NA, 2.30, 1.51, NA, 1.35, 1.81, 2.65)

# 标题行缩进 0,水平行缩进 1
ind <- c(0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1)

一、手写:把四样东西凑齐

这一段的全部内容就是把上面那张表改造成 forest() 认的形状:造 CI 文本列、造空格占位列、把 p 值格式化成字符串、然后按顺序排好列。

#| fig: manual
#| fig-units: cm
#| fig-width: 18
#| fig-height: 12
d <- sg

# ① CI 文本列 —— forest() 不会替你生成
d$`OR (95% CI)` <- ifelse(is.na(est), "",
                          sprintf("%.2f (%.2f, %.2f)", est, lower, upper))

# ② 空格占位列 —— 空格数量就是 CI 面板的宽度
d$` ` <- strrep(" ", 22)

# ③ p 值转成显示用的字符串
d$p_value <- ifelse(is.na(sg$p_value), "",
                    ifelse(sg$p_value < 0.001, "<0.001",
                           sprintf("%.3f", sg$p_value)))

# ④ 排好列序,占位列在第 3 位
d <- d[, c("Subgroup", "No. of patients", " ", "OR (95% CI)", "p_value")]

plot(forest(
  d,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,          # 指的是上面那个 " " 列
  ref_line  = 1,
  xlim      = c(0.5, 3),
  ticks_at  = c(0.5, 1, 2, 3)
))
forest — manual
forest-manual

出来的是能用的图,但表头还顶着 p_value 这种原始列名,缩进、加粗、配色、三线边框全都没有——那些是 edit_plot()add_border() 的活,再写三四十行。

二、一行版

同一份数据交给 plot_forest()注意 data 传的是 sg 本身——没有占位列、没有 CI 文本列,那两列由它插入。

#| fig: wrapped
#| fig-units: cm
#| fig-width: 22
#| fig-height: 20
library(ukbflow)

plot(plot_forest(
  data      = sg,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,                     # 插入之前的位置
  p_cols    = "p_value",             # 这一列按 p 值格式化并加粗
  header    = c("Subgroup", "No. of patients", "",
                "OR (95% CI)", "P value"),
  xlim      = c(0.5, 3),
  ticks_at  = c(0.5, 1, 2, 3),
  save      = FALSE
))
forest — wrapped
forest-wrapped

三线边框、斑马底纹、p < 0.05 加粗、列对齐都是默认就有的。header 的长度是 ncol(sg) + 2,因为那两列已经插进去了;占位列那一位传空字符串。

三、亚组图

加上缩进和按显著性配色。ci_col 接受一个和行数等长的向量,所以显著性配色就是一句 ifelse()

#| fig: subgroup
#| fig-units: cm
#| fig-width: 22
#| fig-height: 20
plot(plot_forest(
  data      = sg,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,
  p_cols    = "p_value",
  indent    = ind,                   # 标题行不缩进,水平行缩进一级
  ci_col    = ifelse(!is.na(sg$p_value) & sg$p_value < 0.05,
                     "#B2182B", "#4575B4"),
  header    = c("Subgroup", "No. of patients", "",
                "OR (95% CI)", "P value"),
  xlim      = c(0.5, 3),
  ticks_at  = c(0.5, 1, 2, 3),
  arrow_lab = c("Lower risk", "Higher risk"),
  save      = FALSE
))
forest — subgroup
forest-subgroup

bold_label 不用传:它默认从 indent 推——缩进为 0 的是父行,自动加粗。

四、结果表直通

上面那三张都是先有效应量、再画图。但效应量本来就是回归跑出来的,那张结果表里已经有点估计和上下限了。

plot_forest() 认这种表:est / lower / upper 都不给的时候,它去看列名,认出来就自己派生。识别规则很简单——CI_lowerCI_upper,再加上 HR / OR / beta / SHR 里的任意一个。连参考线放哪都跟着效应类型走:比值型放 1,beta 放 0。

所以不必真的跑一遍回归,手搭一张同样形状的表就能验证:

#| fig: assoc
#| fig-units: cm
#| fig-width: 28
#| fig-height: 18
res <- data.frame(
  model    = rep(c("Unadjusted", "Age- and sex-adjusted", "Fully adjusted"),
                 each = 3),
  term     = rep(c("Overweight", "Obese", "Severely obese"), times = 3),
  n_events = rep(c(412, 268, 97), times = 3),
  HR       = c(1.18, 1.52, 1.94, 1.14, 1.44, 1.81, 1.09, 1.36, 1.72),
  CI_lower = c(1.02, 1.27, 1.48, 0.99, 1.20, 1.37, 0.94, 1.12, 1.28),
  CI_upper = c(1.37, 1.82, 2.54, 1.32, 1.73, 2.39, 1.27, 1.65, 2.31),
  p_value  = c(0.028, 0.0002, 0.00004, 0.067, 0.0004, 0.0002,
               0.25, 0.0021, 0.0004)
)

plot(plot_forest(
  res,
  xlim     = c(0.9, 3),      # 自动推的范围留白过多,比值型指标上会让刻度没法读
  ticks_at = c(1, 1.5, 2, 3),
  save     = FALSE
))
forest — assoc
forest-assoc

这条路省掉的正是本页开头那一整节:输入的形状根本不用你去凑est / lower / upper 和表头都是派生的。

一点要注意:自动推的 xlim 会为了留白把下界压得很低,比值型指标上刻度会挤成一团(这张表实测推出的下界是 0.0035,而 CI 最小才 0.17),所以 xlimticks_at 通常还是自己给。

版式要再改,就回到前面几节——把 label_col / show_cols 或者干脆 est / lower / upper 显式传进去。任何一个显式给了的参数都会盖掉派生值。

存成文件

plot_forest(save = TRUE, dest = "forest") 会一次写出 png / pdf / jpg / tiff 四份,300 dpi。宽度默认 20 cm,高度不给的话按 行数 × 0.9 + 3 估。

手写路线就是常规的 ggplot2::ggsave() 或者开图形设备再 plot()——森林图是个 gtable,不是 ggplot 对象,ggsave() 要显式把它当 plot 传。

两种做法怎么选

  1. 常规亚组图、多模型对比 → plot_forest() 占位列、CI 文本、p 值格式、加粗、斑马纹、三线边框全在里面,剩下的只是填数据。
  2. 要对数轴、多列 CI、或者版式很特殊 → 手写 forest() x_trans = "log" 这类参数封装没暴露,而比值型指标本来就该用对数轴。
运行环境3 个包 · 2026-08-04

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • forestploter 1.1.3
  • grid 4.5.1
  • ukbflow 0.3.4