这篇复刻的原型是 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 可以拆成五层:
- 右侧半密度:显示分布的大致形状;
- 细灰线:2.5% 到 97.5% 的中心经验区间;
- 粗灰线:25% 到 75% 的中心经验区间;
- 白点:中位数;
- 红色短线和顶部红字:均值及其数值。
底部再写样本量,必要时增加一条横向参考线。每层只承担一个任务,因此即使组数多,读者也不需要在图例和图形之间来回找。

图中粗、细灰线都是观测分布的分位数区间,不是均值的置信区间。具体地说:
- 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"
)
导出后至少检查四件事:
- 顶部均值有没有被裁切;
- 底部样本量与横轴标签是否重叠;
- 细灰线在缩小后是否仍可辨认;
- 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. 参考资料与复刻声明
- 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
- 原论文 Fig. 2 及图注:https://www.nature.com/articles/s41467-022-31012-0
- 作者公开的分析代码与制图数据仓库:https://github.com/LacroixLaurent/NanoForkSpeed
- R 官方
ChickWeight文档:https://stat.ethz.ch/R-manual/R-devel/library/datasets/html/ChickWeight.html - R 官方
density()文档:https://stat.ethz.ch/R-manual/R-devel/library/stats/html/density.html - ggplot2
geom_polygon()文档:https://ggplot2.tidyverse.org/reference/geom_polygon.html - patchwork
plot_layout()文档:https://patchwork.data-imaginist.com/reference/plot_layout.html