【R绘图】Nature Communications 半眼图复刻:分布、区间与多面板排版(保姆级教程)

这篇复刻的原型是 Theulot 等人在 2022 年发表的论文 Genome-wide mapping of individual replication fork velocities using nanopore sequencing ,重点参考其 Fig. 2 的视觉结构。原图展示的是复制叉速度;这里改用 R 自带的 ChickWeight 纵向数据,以便代码无需下载外部文件即可完整运行。

先把边界说清楚:这是图形结构复刻 ,不是对原论文生物学结果的复算。配色、层次和版式借鉴 Fig. 2,数据、变量和解释都来自 ChickWeight。原论文及作者公开的 R 代码仓库均在文末列出。

最终要完成两幅并列图:

  • 左图比较 4 种饲料组在第 21 天的体重分布;
  • 右图先对每只小鸡拟合一条体重---时间直线,再比较个体生长速率分布。

这两个面板看起来相似,但统计单位并不相同。左图的单位是"第 21 天仍有记录的小鸡",右图的单位是"每只小鸡的一条斜率"。这也是整篇教程最重要的地方:先确定每个点代表什么,再谈怎么把图画漂亮。


1. 原图值得复刻的不是颜色,而是信息层级

原论文 Fig. 2 的 half-eye plot 可以拆成五层:

  1. 右侧半密度:显示分布的大致形状;
  2. 细灰线:2.5% 到 97.5% 的中心经验区间;
  3. 粗灰线:25% 到 75% 的中心经验区间;
  4. 白点:中位数;
  5. 红色短线和顶部红字:均值及其数值。

底部再写样本量,必要时增加一条横向参考线。每层只承担一个任务,因此即使组数多,读者也不需要在图例和图形之间来回找。

图中粗、细灰线都是观测分布的分位数区间,不是均值的置信区间。具体地说:

  • 50% 中心经验区间:[Q0.25, Q0.75]
  • 95% 中心经验区间:[Q0.025, Q0.975]

两者描述的是样本值分布在哪,不回答总体均值估计有多精确。把经验区间写成"95% CI"是复刻这类图时很常见的错误。


2. 准备环境与数据

需要的程序包如下:

r 复制代码
library(ggplot2)
library(dplyr)
library(tidyr)
library(purrr)
library(tibble)
library(patchwork)
library(scales)

ChickWeight 有 578 行、50 只小鸡、4 种饲料。每只小鸡从出生起接受多次称重,绝大多数记录到第 21 天,但并非全部个体都有完整随访。

r 复制代码
chick <- ChickWeight |>
  as_tibble() |>
  transmute(
    chick = factor(Chick),
    diet = factor(Diet, levels = 1:4,
                  labels = paste("Diet", 1:4)),
    day = Time,
    weight_g = weight
  )

dim(chick)
# 578   4

n_distinct(chick$chick)
# 50

table(chick$day)

各时间点的记录数并不完全相等。第 0 天有 50 条记录,第 21 天只有 45 条。若直接把 578 行全部塞进一个小提琴图,就把同一只小鸡的重复测量当成了 578 个独立个体。

先看纵向轨迹,往往比先画分布图更有用。

r 复制代码
mean_curve <- chick |>
  group_by(diet, day) |>
  summarise(
    mean_weight = mean(weight_g),
    n = n(),
    .groups = "drop"
  )

p_audit <- ggplot(chick, aes(day, weight_g, group = chick)) +
  geom_line(aes(colour = diet), linewidth = 0.45, alpha = 0.34) +
  geom_line(
    data = mean_curve,
    aes(day, mean_weight, group = diet, colour = diet),
    linewidth = 1.15,
    inherit.aes = FALSE
  ) +
  facet_wrap(~diet, nrow = 1) +
  theme_classic()

这张图能直接看到两件事。第一,个体轨迹差异很大,只比较组均值会丢失不少信息;第二,部分轨迹提前结束,因此"第 21 天体重"和"全程生长速度"不是同一个分析对象。


3. 先定义两个面板各自的统计单位

3.1 左图:固定时间点的横断面分布

左图只保留第 21 天记录:

r 复制代码
endpoint <- chick |>
  filter(day == 21) |>
  select(chick, diet, weight_g)

table(endpoint$diet)
# Diet 1 Diet 2 Diet 3 Diet 4
#     16     10     10      9

这里有 45 只小鸡。不同组样本量不一样,所以 n 必须直接写在图中。密度的宽窄经过组内归一化后主要用于看形状,不能据此判断哪组样本更多。

3.2 右图:每只小鸡只贡献一个生长速率

对第 i 只小鸡拟合一条直线:

weight = a_i + b_i × day + ε

其中,b_i 表示第 i 只小鸡的平均线性生长速率。右图使用的是 50 个 b_i,不是 578 条原始记录。

r 复制代码
growth_rate <- chick |>
  group_by(chick, diet) |>
  nest() |>
  mutate(
    slope_g_day = map_dbl(
      data,
      ~ unname(coef(lm(weight_g ~ day, data = .x))[2])
    ),
    last_day = map_dbl(data, ~ max(.x$day)),
    n_visits = map_int(data, nrow)
  ) |>
  select(-data) |>
  ungroup()

这里的斜率是用于绘图的个体描述量。它不能替代正式的纵向混合模型:小鸡生长曲线明显并非严格线性,而且不同个体的随访长度也不完全一致。若研究问题是检验饲料效应,应该进一步建立含时间、饲料及其交互项的混合效应模型。


4. 生成均值、中位数和两档中心区间

把摘要过程写成函数,两个面板就能共用同一套规则。

r 复制代码
summarise_distribution <- function(data, group_col, value_col) {
  data |>
    group_by(.data[[group_col]]) |>
    summarise(
      n = sum(is.finite(.data[[value_col]])),
      mean = mean(.data[[value_col]], na.rm = TRUE),
      q025 = quantile(.data[[value_col]], 0.025,
                      na.rm = TRUE, names = FALSE),
      q25 = quantile(.data[[value_col]], 0.25,
                     na.rm = TRUE, names = FALSE),
      median = median(.data[[value_col]], na.rm = TRUE),
      q75 = quantile(.data[[value_col]], 0.75,
                     na.rm = TRUE, names = FALSE),
      q975 = quantile(.data[[value_col]], 0.975,
                      na.rm = TRUE, names = FALSE),
      .groups = "drop"
    ) |>
    rename(group = 1) |>
    mutate(group = factor(group), x = as.numeric(group))
}

endpoint_summary <- summarise_distribution(
  endpoint, "diet", "weight_g"
)

growth_summary <- summarise_distribution(
  growth_rate, "diet", "slope_g_day"
)

实跑得到的第 21 天平均体重分别为 177.8、214.7、270.3 和 238.6 g;个体线性生长速率均值分别为 5.85、8.61、11.42 和 9.52 g/day。

这些数字是描述结果,不等于"Diet 3 显著优于其他组"。本图没有进行组间假设检验,不能用均值排序代替统计推断。


5. 不依赖扩展几何对象,手工构造半密度

原论文的方法部分提到使用 ggdist 绘制 half-eye plot。为了把几何原理讲清楚,这里不用现成的 stat_halfeye(),而是用 stats::density() 计算核密度,再把密度值转换为横坐标偏移。

设第 j 组位于横坐标 j,它在纵坐标 y 处的核密度为 f_j(y)。右半边的横坐标写成:

x = j + w × f_j(y) / max[f_j(y)]

其中,w 控制半密度的最大宽度。本例取 0.36。

r 复制代码
make_half_eye_density <- function(data, group_col, value_col,
                                  width = 0.36,
                                  adjust = 0.9,
                                  grid_n = 256) {
  group_levels <- levels(factor(data[[group_col]]))

  map_dfr(seq_along(group_levels), function(i) {
    this_group <- group_levels[i]
    values <- data[[value_col]][
      as.character(data[[group_col]]) == this_group
    ]
    values <- values[is.finite(values)]

    den <- density(
      values,
      n = grid_n,
      adjust = adjust,
      cut = 1.5
    )

    # 让多边形两端准确回到中轴
    den$y[c(1, length(den$y))] <- 0
    scaled_density <- den$y / max(den$y) * width

    tibble(
      group = this_group,
      x = c(i, i + scaled_density, i),
      y = c(min(den$x), den$x, max(den$x)),
      order = seq_len(length(den$x) + 2)
    )
  }) |>
    mutate(group = factor(group, levels = group_levels))
}

adjust 不是越小越"真实"。数值太小会把小样本中的随机起伏画成多个尖峰;太大则会把可能存在的结构抹平。本例每组只有 9--20 个个体,adjust = 0.9 是在保留形状和避免锯齿之间做的折中。换数据后应重新检查,而不是把这个数当作固定模板。

cut = 1.5 允许密度曲线在观测极值外延伸一小段并回到接近 0。若变量存在不可越过的边界,例如比例必须在 0--1 之间,最好采用边界修正、变量变换或截断后的密度估计,不能任由核密度越界。

生成两个多边形坐标表:

r 复制代码
endpoint_density <- make_half_eye_density(
  endpoint, "diet", "weight_g"
)

growth_density <- make_half_eye_density(
  growth_rate, "diet", "slope_g_day"
)

geom_polygon() 会按 group 把每组坐标首尾连接并填充,因此每个组都必须有独立分组标识;如果漏掉 group = group,四个半密度可能被错误连成一个大多边形。


6. 把五层信息叠到同一张图

配色采用同一色相的四级蓝色。它既保留组间区分,也不会与代表均值的红色争夺注意力。

r 复制代码
nature_blue <- c(
  "Diet 1" = "#C7E9F1",
  "Diet 2" = "#8FD0DF",
  "Diet 3" = "#4FA9C5",
  "Diet 4" = "#176B87"
)

mean_red <- "#D64B4B"
ink <- "#2B2B2B"
mid_grey <- "#777777"

核心图层如下。为了让区间与密度分开,区间整体向左移动 0.08;半密度只向右展开。

r 复制代码
ggplot() +
  geom_polygon(
    data = endpoint_density,
    aes(x = x, y = y, group = group, fill = group),
    alpha = 0.92,
    colour = NA
  ) +
  geom_linerange(
    data = endpoint_summary,
    aes(x = x - 0.08, ymin = q025, ymax = q975),
    linewidth = 0.55,
    colour = mid_grey
  ) +
  geom_linerange(
    data = endpoint_summary,
    aes(x = x - 0.08, ymin = q25, ymax = q75),
    linewidth = 2.3,
    lineend = "round",
    colour = mid_grey
  ) +
  geom_point(
    data = endpoint_summary,
    aes(x = x - 0.08, y = median),
    shape = 21,
    size = 2,
    stroke = 0.35,
    fill = "white",
    colour = ink
  ) +
  geom_segment(
    data = endpoint_summary,
    aes(x = x - 0.17, xend = x + 0.01,
        y = mean, yend = mean),
    linewidth = 1.05,
    colour = mean_red,
    lineend = "round"
  )

注意图层顺序。先画密度,再画区间、点和均值短线,否则密度填充会盖住摘要标记。


7. 左图:第 21 天体重分布

左图加入三个额外元素:顶部均值、底部样本量,以及 45 个第 21 天观测的合并均值参考线。

图上可以直接读到:

  • Diet 1:n = 16,均值 178 g,中位数 166 g;
  • Diet 2:n = 10,均值 215 g,中位数 212.5 g;
  • Diet 3:n = 10,均值 270 g,中位数 281 g;
  • Diet 4:n = 9,均值 239 g,中位数 237 g。

Diet 3 的分布整体位置较高,Diet 1 较低;Diet 2 的分布更分散。这里最容易误读的是半密度面积:每组密度都按自身最大值缩放,它反映组内形状,不编码组间样本量。样本量只能看底部的 n

此外,第 21 天缺失的 5 只小鸡并非自动"无关紧要"。若提前结束随访与体重变化有关,完整病例分布可能产生选择偏倚。图中把分析对象写成"第 21 天仍有记录的小鸡",正是为了避免把它说成全部 50 只小鸡的终点分布。


8. 右图:个体生长速率分布

每只小鸡只贡献一个斜率,所以 4 组样本量恢复为 20、10、10、10。均值分别为 5.8、8.6、11.4 和 9.5 g/day。

这张图和左图不能互相替代:

  • 终点体重同时受出生体重、早期变化和后期变化影响;
  • 斜率概括了整个观测期的平均变化速度;
  • 提前结束随访的个体不能进入第 21 天横断面,却仍可提供一条基于已有记录估计的斜率。

不过,简单斜率会把弯曲的生长轨迹压成一条直线。它适合说明"把重复测量归并到个体层级"的绘图思路,不适合作为最终纵向模型的全部答案。


9. 双面板排版:相似外观,不强行共用纵轴

两个面板使用相同的颜色、线宽和信息层级,但纵轴量纲不同。左图是克,右图是克/天,因此不应为了视觉整齐而强行使用同一纵轴范围。

r 复制代码
final_plot <- p_endpoint + p_growth +
  plot_layout(widths = c(1, 1), guides = "collect") +
  plot_annotation(
    tag_levels = "a",
    title = "Nature-style half-eye distributions from longitudinal data",
    subtitle = paste(
      "Visual-structure reconstruction using the built-in",
      "ChickWeight dataset"
    )
  )

这张最终图没有加显著性星号。原因很简单:本例的任务是复刻分布呈现,不是先做一串两两检验再把结果堆上去。若后续建立了事先指定的统计模型,可以在图注中报告效应量、区间和校正后的检验结果;没有模型时,星号只会给描述图制造不必要的确定感。


10. 出版文件怎么导出

屏幕预览用 300--320 dpi PNG 足够;投稿或后期排版再输出 PDF 和 600 dpi LZW 压缩 TIFF。

r 复制代码
ggsave(
  "05_nature_style_final.png",
  final_plot,
  width = 13.4,
  height = 7.2,
  dpi = 320,
  bg = "white"
)

ggsave(
  "Nature_half_eye_final.pdf",
  final_plot,
  width = 13.4,
  height = 7.2,
  device = cairo_pdf,
  bg = "white"
)

ggsave(
  "Nature_half_eye_final.tiff",
  final_plot,
  width = 13.4,
  height = 7.2,
  dpi = 600,
  device = "tiff",
  compression = "lzw",
  bg = "white"
)

导出后至少检查四件事:

  1. 顶部均值有没有被裁切;
  2. 底部样本量与横轴标签是否重叠;
  3. 细灰线在缩小后是否仍可辨认;
  4. PDF 中字体、线宽和面板标签是否保持一致。

本例最终 PNG 为 4288 × 2304 像素;PDF 和 600 dpi TIFF 也已由同一对象导出。不要分别手工调整三个版本,否则很容易出现标题、字号或数据更新不同步。


11. 五个最容易踩的坑

11.1 把 95% 经验区间叫成 95% 置信区间

经验区间描述观测值,置信区间描述估计量的不确定性。两者计算对象不同,图注必须写清楚。

11.2 把重复测量的每一行当成独立样本

本例 578 行来自 50 只小鸡。右图先在个体层级计算斜率,避免把重复记录伪装成巨大样本量。

11.3 用密度宽度比较样本量

每组半密度都单独归一化,宽度表示相对密度,不表示 n。样本量必须另行标注。

11.4 只调配色,不检查带宽

核密度形状受带宽直接影响。小样本尤其要比较不同 adjust 下的结果,避免把噪声画成多峰。

11.5 为了"论文感"强塞显著性星号

图形复刻不等于统计结论复刻。先确定模型、对比和多重校正,再决定是否标注检验结果。


12. 如何迁移到自己的数据

如果数据已经是"一行一个独立样本",至少准备三列:

text 复制代码
sample_id    group    value
S001         A        12.4
S002         A        10.8
S003         B        15.1

把脚本中的:

r 复制代码
endpoint, "diet", "weight_g"

替换为:

r 复制代码
your_data, "group", "value"

即可复用摘要函数和半密度函数。

如果是重复测量数据,则还要先决定图中统计单位。常见做法包括:

  • 固定时间点取每个个体一个值;
  • 计算每个个体的变化值;
  • 计算曲线下面积;
  • 从适当模型中提取个体层级预测或随机效应;
  • 直接用混合模型估计组间差异,再把模型结果与原始分布分开画。

哪一种都不能仅凭"画出来最好看"来选。统计单位应由研究问题决定。


13. 参考资料与复刻声明

  1. Theulot B, Lacroix L, Arbona JM, et al. Genome-wide mapping of individual replication fork velocities using nanopore sequencing . Nature Communications, 2022, 13:3295. DOI:https://doi.org/10.1038/s41467-022-31012-0
  2. 原论文 Fig. 2 及图注:https://www.nature.com/articles/s41467-022-31012-0
  3. 作者公开的分析代码与制图数据仓库:https://github.com/LacroixLaurent/NanoForkSpeed
  4. R 官方 ChickWeight 文档:https://stat.ethz.ch/R-manual/R-devel/library/datasets/html/ChickWeight.html
  5. R 官方 density() 文档:https://stat.ethz.ch/R-manual/R-devel/library/stats/html/density.html
  6. ggplot2 geom_polygon() 文档:https://ggplot2.tidyverse.org/reference/geom_polygon.html
  7. patchwork plot_layout() 文档:https://patchwork.data-imaginist.com/reference/plot_layout.html
相关推荐
C++、Java和Python的菜鸟1 小时前
第10章 后端Web进阶(Maven高级)
开发语言·python
2501_932750262 小时前
Android registerForActivityResult 详解
android
神罚天下ET2 小时前
.NET 新增功能系列文章——C# 中的新增功能
开发语言·c#·.net
所愿ღ2 小时前
SSM框架-Spring3
java·开发语言·笔记·spring
影视飓风TIM3 小时前
C++ STL 完整入门笔记[8]:deque 底层原理深度剖析
开发语言·c++·笔记
zmzb01033 小时前
C++课后习题训练记录Day176
开发语言·c++
Rabitebla3 小时前
C++ STL 之 set 详解:从底层红黑树到实际应用
开发语言·数据结构·c++·算法·leetcode
520拼好饭被践踏3 小时前
JAVA+Agent学习day26
java·开发语言·数据结构·学习·agent
157092511343 小时前
优先队列:从Java源码到实战
开发语言·python