Folioevanzhou.org
← 返回 Tessera
R4 张图2026-08-04

forest

One row per effect estimate, each a point and an interval measured against a shared line of no effect.

示例数据
forest_subgroupsforest_models
配色
heat_lightthree_body
语言
R
成图预览另有 3 张在配方里
forest

Introduction

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

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

读的时候按这个顺序:

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

Example Data

森林图要的输入是:每行一个效应量——点估计 + 置信区间上下限,外加要一起显示的文本列。

这是这张图唯一麻烦的地方,画的部分反而是机械的。forestploter::forest() 要的不是一张表,而是拆开的四样东西:

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

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

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

forest_subgroups 和 forest_models 分开保存两种分析结果。前者是 9 行亚组估计,后者是 BMI 三个水平 × 三种校正模型的 9 行 Cox 结果。CSV 只放分析数据;亚组标题与缩进在绘图时根据 group 生成。

library(forestploter)
library(grid)
library(biopalette)

# 公开地址,和数据集页上「下载 CSV」给的是同一个 —— 不写仓库相对路径:
# 那个目录不进仓库,读者 clone 下来也没有这个文件,这段代码就跑不了
subgroups <- read.csv(
  "https://assets.evanzhou.org/tessera/csv/forest_subgroups.csv"
)
models <- read.csv(
  "https://assets.evanzhou.org/tessera/csv/forest_models.csv"
)

# CSV 没有展示专用的标题行。Overall 直接保留;其余每个 group 在这里插入
# 一行没有效应量的标题,标题行展示该组的交互作用 p 值。
rows <- do.call(rbind, lapply(unique(subgroups$group), function(g) {
  members <- subgroups[subgroups$group == g, ]
  leaves <- data.frame(
    label   = members$level,
    depth   = if (g == "Overall") 0L else 1L,
    n       = members$n,
    p_value = members$p_value,
    p_interaction = NA_real_,
    est     = members$HR,
    lower   = members$CI_lower,
    upper   = members$CI_upper
  )
  if (g == "Overall") return(leaves)

  header <- data.frame(
    label = g, depth = 0L, n = NA_integer_, p_value = NA_real_,
    p_interaction = unique(members$p_interaction)[1L],
    est = NA_real_, lower = NA_real_, upper = NA_real_
  )
  rbind(header, leaves)
}))

# 文本列和三个数值向量分开 —— 这就是上面那张表说的形状
sg <- data.frame(
  Subgroup          = rows$label,
  `No. of patients` = ifelse(is.na(rows$n), "",
                             format(rows$n, big.mark = ",", trim = TRUE)),
  p_value           = rows$p_value,
  p_interaction     = rows$p_interaction,
  check.names = FALSE      # 列名里有空格和点,不让 R 改写
)

est   <- rows$est
lower <- rows$lower
upper <- rows$upper

# 缩进从刚才插入的标题行推,CSV 本身不承担展示逻辑
ind <- rows$depth

nrow(sg)
#> 12

Palettes

亚组图用 heat_light 的暖红标显著、冷蓝标不显著;模型比较图用 three_body 的三个颜色依次表示未校正、年龄与性别校正及完全校正。颜色全部来自已有色板,不在 recipe 里另写一套 HEX。

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

斑马底纹的作用是横向对齐:行多的时候,眼睛从最左边的标签扫到最右边的 p 值容易串行。它刻意是无彩色的浅灰,不取自任何色板——底纹一旦带上色相,就会被当成第三种编码。

heat_light <- get_palette("heat_light", type = "qualitative")
SIG    <- heat_light[1]   # 暖红,p < 0.05
NONSIG <- heat_light[2]   # 冷蓝

three_body <- get_palette("three_body", type = "qualitative")
MODEL_COL <- setNames(
  three_body,
  c("Unadjusted", "Age and sex adjusted", "Fully adjusted")
)

STRIPE <- "#F0F0F0"       # 斑马底纹,浅到几乎看不见就对了

Recipe

No. Method Input Data Palettes
1 forestploter d(凑好形状的表) —
2 ukbflow sg —
3 ukbflow sg SIG / NONSIG
4 ukbflow models(9 行原表) three_body

1 · 手写 forest()

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

#| fig: manual
#| fig-units: cm
#| fig-width: 24
#| fig-height: 10
d <- sg

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

# ② 空格占位列 —— 空格数量就是 CI 面板的宽度,不是随手写的 22。
#    行数一变最容易忘掉调它,结果 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)))
d$p_interaction <- ifelse(is.na(sg$p_interaction), "",
                          sprintf("%.3f", sg$p_interaction))

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

p_manual <- forest(
  d,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,          # 指的是上面那个 " " 列
  ref_line  = 1,
  xlim      = c(0.7, 2.6),
  # 不给 ticks_at 会在 xlim 上五等分:c(0.7, 2.6) 得到 0.7 / 1.175 / 1.65 / 2.125 / 2.6,
  # 无效线 1 甚至不在刻度上
  ticks_at  = c(0.7, 1, 1.5, 2, 2.5)
)

# forest() 按文字自动算出的列宽偏松;这里按这六列的实际内容收紧。
p_manual$widths <- grid::unit(c(3, 35, 35, 52, 40, 22, 30, 3), "mm")
p_manual <- add_border(
  p_manual, part = "header", row = 1, where = "top",
  gp = gpar(lwd = 3)
)
p_manual <- add_border(
  p_manual, part = "header", row = 1, where = "bottom",
  gp = gpar(lwd = 3)
)
p_manual <- add_border(
  p_manual, part = "body", row = nrow(d), where = "bottom",
  gp = gpar(lwd = 3)
)
plot(p_manual)
forest — manual
forest-manual

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

2 · 一行版

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

#| fig: wrapped
#| fig-units: cm
#| fig-width: 24
#| fig-height: 15
library(ukbflow)

plot(plot_forest(
  data      = sg,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,                     # 插入之前的位置
  p_cols    = c("p_value", "p_interaction"), # 两列 p 值都统一格式化
  # 不给 header 会把原始列名当表头:占位列顶着 gap_ci、p 值列顶着 p_value 进图。
  # 长度是 ncol(sg) + 2,因为那两列已经插进去了;占位列那一位传空字符串
  header    = c("Subgroup", "No. of patients", "",
                "HR (95% CI)", "P value", "P interaction"),
  xlim      = c(0.7, 2.6),
  ticks_at  = c(0.7, 1, 1.5, 2, 2.5),
  col_width = c(3, 35, 35, 52, 40, 22, 30, 3), # mm,含两侧留白
  row_height = c(3, 10, rep(8, 14), 8),         # mm,含表头和轴区
  save      = FALSE
))
forest — wrapped
forest-wrapped

三线边框、斑马底纹、p < 0.05 加粗、列对齐都是默认就有的。

3 · 亚组图

加上缩进和按显著性配色。两者都从 ind 和 est 推,没有一个手写的常量。

#| fig: subgroup
#| fig-units: cm
#| fig-width: 24
#| fig-height: 15
plot(plot_forest(
  data      = sg,
  est       = est,
  lower     = lower,
  upper     = upper,
  ci_column = 3,
  p_cols    = c("p_value", "p_interaction"),
  # 缩进用的是不断行空格,不是普通空格 —— 普通空格会被 grid 的文本渲染吃掉,
  # 亚组的层级就没了。indent 参数内部处理的就是这件事
  indent    = ind,
  # 标题行没有估计值,所以不上色;其余按显著性分两档
  ci_col    = ifelse(is.na(est), NA,
                     ifelse(sg$p_value < 0.05, SIG, NONSIG)),
  bg_col    = STRIPE,
  header    = c("Subgroup", "No. of patients", "",
                "HR (95% CI)", "P value", "P interaction"),
  xlim      = c(0.7, 2.6),
  ticks_at  = c(0.7, 1, 1.5, 2, 2.5),
  arrow_lab = c("Lower risk", "Higher risk"),
  col_width = c(3, 35, 35, 52, 40, 22, 30, 3),
  row_height = c(3, 10, rep(8, 14), 8),
  save      = FALSE
))
forest — subgroup
forest-subgroup

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

4 · 结果表直通

上面那三张都要先把亚组结果摊成 sg。但 models 本来就是回归结果表,点估计和上下限已经在列里了。

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

所以这一版直接把 9 行原样喂进去:三个 BMI 水平各有三种模型。颜色由 model 映射到 three_body,校正之后估计怎样向无效线移动一眼就能对照。

#| fig: assoc
#| fig-units: cm
#| fig-width: 24
#| fig-height: 13
plot(plot_forest(
  models,
  ci_col   = unname(MODEL_COL[models$model]),
  # 自动推的 xlim 会为了留白把下界压得很低,比值型指标上刻度会挤成一团,
  # 所以 xlim 和 ticks_at 通常还是自己给
  xlim     = c(0.7, 2.6),
  ticks_at = c(0.7, 1, 1.5, 2, 2.5),
  col_width = c(3, 43, 30, 22, 52, 40, 22, 3),
  row_height = c(3, 10, rep(8, 11), 8),
  save     = FALSE
))
forest — assoc
forest-assoc

这条路省掉的正是上面 Example Data 那一整节:输入的形状根本不用去凑,est / lower / upper 和表头都是派生的。版式要再改就回到前面几节——把 label_col / show_cols 或者干脆 est / lower / upper 显式传进去,任何一个显式给了的参数都会盖掉派生值。

存成文件。 plot_forest(save = TRUE, dest = "forest") 会一次写出 png / pdf / jpg / tiff 四份,300 dpi;宽度默认 20 cm,高度不给的话按 行数 × 0.9 + 3 估。手写路线就是常规的 ggsave() 或者开图形设备再 plot()——森林图是个 gtable 不是 ggplot 对象,ggsave() 要显式把它当 plot 传。

尺寸不用猜,量得出来:

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

量出来往上取整、再多给两三厘米当留白。单位一律用厘米:gtable 内部是毫米、save_width / save_height 是厘米、R 图形设备默认按英寸开——三种混着用,脑子里换算一次就错一次。行数变了就重量一次。

Constraints

  • 别横向比亚组之间的显著性。 A 组显著、B 组不显著,不等于两组效应不同;应看组标题行单独展示的交互作用检验。
  • 区间碰到无效线是"没能排除无效",不是"证明了无效"。 两者在图上长得一样。
  • 区间的宽度与效应大小无关。 它只反映样本量或事件数。
  • 比值型指标在线性轴上不对称。 HR = 0.5 和 HR = 2 是一对等效的相反效应,线性轴上却一个离 1 很近、一个很远。要对数轴得用 forest(x_trans = "log")——plot_forest() 没有暴露这个参数,这是它目前做不到的事。
  • 画布小了会静默裁掉。 森林图是个 gtable,行高列宽按毫米写死(plot_forest() 的自动值:顶部 8 mm、表头 12 mm、每个数据行 10 mm、底部 15 mm)。画布比它小,超出的部分直接被切掉,不报错也不警告——页面上就是一张顶线贴边、第一行就是数据的图,除非你数得出表头应该在那儿,否则根本看不出少了东西。

四个 recipe 怎么比

  1. 常规亚组图 → recipe 3。缩进、显著性配色、三线边框、斑马纹都在里面。
  2. 只要一张能用的图 → recipe 2。
  3. 结果表原样就要出图 → recipe 4。输入的形状不用自己凑。
  4. 要对数轴、多列 CI、或者版式很特殊 → recipe 1。x_trans = "log" 这类参数封装没暴露,而比值型指标本来就该用对数轴。
运行环境4 个包 · 2026-09-02T11:59:26.171+0800

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

  • biopalette 0.2.2
  • forestploter 1.1.3
  • grid 4.5.1
  • ukbflow 0.4.0