你手里要是有同一批样本在两个时间点、两种处理条件下的测量数据,大概都会遇到这个问题:普通的箱线图只能看两组分布的中位数、离散程度,却完全看不出同一个个体到底是在升高还是在降低。而用ggplot2画一张配对连线箱线图,就能同时把“整体分布”和“个体变化方向”放在一张图里——箱线图展示两组数据的集中趋势,散点和连线则把每个样本的前后变化轨迹画出来。这篇文章我会用一套可复现的模拟数据,从数据整理、坐标变换、图层叠放,一直讲到发表前的细节美化,顺便把我在实际分析中踩过的几个坑也一起拆开说清楚。适合正在处理配对数据的R语言初学者,也适合想把这类型图表画得更严谨的科研人员。
1. 配对连线箱线图到底解决了什么问题
1.1 普通箱线图看不见的“个体变化方向”
我最早接触这张图,是在处理一批治疗前、治疗后指标的数据时。当时先用普通箱线图画了两组,看上去治疗后中位数明显更高,似乎结论很完美。但顺手打印了一下每个样本的前后差值,发现20个人里其实有4个人是下降的,甚至有一个下降幅度非常大。这个信息在普通箱线图上完全看不出来,因为箱线图只描述分布,不描述个体轨迹。
配对连线箱线图的价值就在这里:它为每一个样本画一条连线,连线的斜率就是该样本的变化方向和幅度。如果大部分线从治疗前斜向上到治疗后,结论一目了然;如果有的线朝上、有的线朝下,那种“整体上升但个体分化严重”的信号也会非常直观。换句话说,它不只是一个漂亮的图形技巧,更是一种数据质控和结果核查的手段。
1.2 适用场景与使用边界
这张图最适合的数据结构,是同一个观察对象在两种条件下的测量结果。典型例子包括同一批患者在治疗前后的生化指标、同一批小鼠在给药前后的体重、同一批用户在改版前后的停留时长。设计上一般会和配对样本t检验或者Wilcoxon符号秩检验配合使用,图形展示个体趋势,统计检验给出p值。
但也要注意边界。第一,如果两组数据完全来自独立样本,比如实验组和对照组各20人,那就不适合画连线,因为连线会凭空制造出“配对关系”,误导读者。第二,如果处理水平超过两个,比如0天、7天、14天三个时间点,虽然也可以用连线的思路,但更适合画折线图加误差带,配对箱线图的优势会明显减弱。第三,样本量特别大的时候,几十上百条线条挤在一起,会变成一团线球,这时我通常会把箱线图保留全部样本,只随机抽取一部分个体画点线,既能展示趋势又不至于看不清。
2. 画图之前的数据整理比画图本身更容易翻车
2.1 三种数据结构的相互转换
ggplot2的工作方式决定了它更喜欢长表:每一行是一次观测,至少包含三列——个体ID、条件变量、测量值。但我们日常拿到的原始数据往往是宽表:每一行是一个个体,治疗前和治疗后各占一列。
宽表转长表,我用tidyr里的pivot_longer,一行就能搞定。下面是一份20个样本的模拟数据,也是本文所有图的基础。
set.seed(2024) n <- 20 before <- rnorm(n, mean = 10, sd = 2) after <- before + rnorm(n, mean = 1.5, sd = 1.5) df_wide <- data.frame( id = 1:n, before = before, after = after ) library(tidyr) library(dplyr) df_long <- df_wide %>% pivot_longer( cols = c(before, after), names_to = "condition", values_to = "value" ) head(df_long)这里我故意把ID保留成整数,后面会说到为什么这会成为一个坑。转换后df_long应该有40行,每个ID出现两次,分别对应before和after。在动手画图前,可以先用table(df_long$id)确认每个ID都是2次,如果出现3次或者1次,先回头处理数据,不要急着画图。
2.2 因子顺序:决定x轴顺序和连线方向
这是最容易翻车的地方。ggplot2处理x轴字符时默认按字母排序,所以“after”会排在“before”前面,画出来治疗在左、治疗前在右,怎么看怎么别扭。更关键的是,这会改变连线的视觉方向——虽然个体变化幅度不变,但图上连线的斜率方向是反的。
解决办法是显式指定因子水平。
df_long$condition <- factor(df_long$condition, levels = c("before", "after"))如果你想把图上的标签改成中文,可以在这里直接加labels参数,或者到绘图时用scale_x_discrete调整。我更喜欢在factor阶段就固化顺序,因为之后如果要用as.numeric(condition)计算偏移坐标,顺序不对会直接导致偏移方向反掉。
2.3 检查数据完整性:重复ID和缺失测量
配对连线图对数据完整性要求很高。一个ID如果在某个时间点缺失,箱线图会自动忽略那一组,但连线层会因为没有第二个端点而行为异常。另一个常见问题是一个ID在同一个条件下出现多次,比如生化指标做了两次技术重复,这时候如果直接画线,同一个ID会多出一条段。
我一般画图前会跑两条检查:
df_long %>% group_by(id) %>% summarise(n = n()) %>% filter(n != 2) df_long %>% filter(is.na(value)) %>% as.data.frame()第一个检查每个ID是否恰好出现两次,第二个检查有没有缺失值。如果有缺失,最稳妥的处理是剔除该ID的两次观测,不要只删掉其中一行,否则连线会悬空。技术重复则需要在做长表之前先取均值,或者用单独的列表示重复序号,再在group里联合起来——但那样连线的含义就变了,我不建议为了画图强行塞进重复测量。
3. 核心代码三层结构:箱线图、散点、连线的正确叠加次序
3.1 直接能跑的完整代码
先把一套最基础的可以运行的代码放在前面。
library(ggplot2) p_base <- ggplot(df_long, aes(x = condition, y = value)) + geom_boxplot( aes(fill = condition), width = 0.45, alpha = 0.35, outlier.shape = NA ) + geom_point( aes(color = condition), size = 1.8, position = position_jitter(width = 0.06, seed = 123) ) + geom_line( aes(group = as.character(id)), color = "grey50", alpha = 0.5, linewidth = 0.5 ) + scale_fill_manual(values = c("before" = "#5B9BD5", "after" = "#ED7D31")) + scale_color_manual(values = c("before" = "#5B9BD5", "after" = "#ED7D31")) + theme_classic(base_size = 14) + labs(x = NULL, y = "指标值", title = "治疗前后配对比较") + theme(legend.position = "none") print(p_base)这里的顺序是:先画箱线图垫底,再画连线,最后画散点。之所以把散点放在连线的上面,是为了让点压住线的端点,视觉上更干净。如果把连线放在最上面,线条会从点的身上穿过,尤其是当点比较小的时候,会显得很杂乱。
3.2 为什么outlier.shape=NA和width设置很关键
箱线图默认会把分布中1.5倍四分位距以外的点标记为离群值,并直接画在图上。如果我们不设置outlier.shape = NA,这些离群值会出现在箱线图层里,然后在散点层里同一个点又被画一遍,在线层里又被线穿一遍,图上一个点三层重叠,非常混乱。
因为我们要自己用散点层展示全部个体数据,所以箱线图里的离群值必须隐藏。width控制箱体的宽度,设成0.45左右比较合适,太宽会让箱体把旁边的点线盖住,太窄则视觉上不够稳定。alpha = 0.35让箱体半透明,底下的连线和点能够透出来,这也是为什么基础版本里即使线条多一点也不会完全挡死的原因。
3.3 group参数与连线逻辑
连线层的关键在于aes(group = as.character(id))。geom_line会把同一个group内的数据按照x轴的顺序依次连接起来。这里有两个细节值得注意。
第一,group必须对应“一个完整个体”,不能是条件变量本身。如果误写成group = condition,那只会画出两条垂直的竖线,因为每个组内只有一个x位置。第二,ID列如果是数字,建议显式转换成语符串。原因在于ggplot2对连续型变量和离散型变量的处理逻辑不同,数值ID有时候会被当作连续变量,分组行为会变得很奇怪,轻则连线不对,重则出现很多跨样本的斜线。
4. 连接线与散点不对齐:三种实用偏移方案
4.1 position_jitter会对连线造成什么破坏
如果你在基础代码里用过position_jitter,一定会发现一个问题:点层加了抖动之后在x方向随机左右移动,线上的端点却还固定在原始x坐标上,两者对不上。更麻烦的是,每次运行图,点的抖动位置都不一样,今天这张图和明天那张图看起来像两张不同的图。
这个问题的根源在于,position_jitter是在绘图阶段临时生成随机位移的,而geom_line并不会共享这个位移。解决思路也很简单:把x方向的位移从“绘图阶段随机生成”改成“数据框里提前计算好的一列”,让点和线共用同一列坐标。
4.2 方案一:固定偏移两侧箱体
最常见的做法是把x轴当成数值坐标,让前一组点线整体偏右一点,后一组整体偏左一点,两组点线集中在两个箱体中间。
df_long$xjit <- as.numeric(df_long$condition) + ifelse(df_long$condition == "before", 0.08, -0.08) p_offset <- ggplot() + geom_boxplot( data = df_long, aes(x = as.numeric(condition), y = value, group = condition), width = 0.4, alpha = 0.35, fill = "grey90", outlier.shape = NA ) + geom_line( data = df_long, aes(x = xjit, y = value, group = as.character(id)), color = "grey40", alpha = 0.5 ) + geom_point( data = df_long, aes(x = xjit, y = value, color = condition), size = 1.8 ) + scale_color_manual(values = c("before" = "#3B7DD8", "after" = "#D83B3B")) + scale_x_continuous(breaks = c(1, 2), labels = c("治疗前", "治疗后")) + theme_classic(base_size = 14) + theme(legend.position = "none") print(p_offset)这个方案的优点是简单可控,所有个体共享同一个偏移量,图面结构整齐,非常适合样本量为20到50的数据。它的缺点是当样本量偏大时,所有线条还是集中在一个很窄的带状区域里,重叠问题依然存在。
4.3 方案二:按个体ID添加共享随机偏移
更精细的做法是让每个ID拥有一个随机的x偏移,并且这个偏移在before和after两个时间点完全相同。这样每一个个体的线段会稍微错开一个角度,视觉上不再挤成一条直线,但线和点始终严格对齐。
set.seed(42) df_jit <- df_long %>% group_by(id) %>% mutate(xoff = runif(1, -0.06, 0.06)) %>% ungroup() %>% mutate(xjit = as.numeric(condition) + xoff) p_jit <- ggplot() + geom_boxplot( data = df_jit, aes(x = as.numeric(condition), y = value, group = condition), width = 0.4, alpha = 0.35, fill = "grey90", outlier.shape = NA ) + geom_line( data = df_jit, aes(x = xjit, y = value, group = as.character(id)), color = "grey40", alpha = 0.6 ) + geom_point( data = df_jit, aes(x = xjit, y = value, color = condition), size = 1.8 ) + scale_color_manual(values = c("before" = "#3B7DD8", "after" = "#D83B3B")) + scale_x_continuous(breaks = c(1, 2), labels = c("治疗前", "治疗后")) + theme_classic(base_size = 14) + theme(legend.position = "none") print(p_jit)这里的关键是group_by(id)之后用runif(1, -0.06, 0.06),同一ID的两个时间点拿到同一个随机数,所以点在x方向上是一起平移的,线段不会歪斜。这个方法是我现在处理这类图的首选,尤其是样本量在30以上时,它既保证了点的对齐,又能在一定程度上减少线条的重叠。
4.4 三个方案怎么选
为了让你一眼看懂区别,我把这三种方案放在同一张表里对比。
| 方案 | 实现方式 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 直接position_jitter | 绘图阶段随机抖动散点 | 代码最短,够快 | 点和线不对齐,结果不可复现 | 快速探索,不用于正式图表 |
| 固定偏移 | 数据框里生成xjit列 | 严格对齐,结构清晰 | 条数多时仍会重叠 | 样本量20到50,正式图表 |
| 按ID共享随机偏移 | 每个ID一个xoff,点和线共用 | 对齐且相对分散 | 偏移范围需要调整 | 样本量偏大,线条密集场景 |
我的习惯是正式图表一律在数据框里生成坐标列,要么固定偏移,要么按ID随机偏移,绝不在图层里临时加position_jitter。这样做还有一个额外好处:同样的数据框,同样的代码,任何时候重跑,图的布局都是稳定的,这对写报告和投稿都非常重要。
5. 发表级别:趋势颜色、显著性标记与主题定制
5.1 给上升与下降的线条赋予颜色
当样本里有部分个体上升、部分个体下降时,把所有连线都做成灰色会损失很多信息。我更建议先计算每个ID的变化方向,然后让线条带上方向颜色,让上升和下降的趋势一目了然。
df_wide$direction <- ifelse(df_wide$after - df_wide$before >= 0, "上升", "下降") df_dir <- df_long %>% left_join(df_wide %>% select(id, direction), by = "id") p_dir <- ggplot(df_dir, aes(x = condition, y = value)) + geom_boxplot( width = 0.35, alpha = 0.25, fill = "grey95", outlier.shape = NA ) + geom_line( aes(group = as.character(id), color = direction), alpha = 0.65, linewidth = 0.6 ) + geom_point( aes(color = direction), size = 2, alpha = 0.8, position = position_jitter(width = 0.05, seed = 123) ) + scale_color_manual(values = c("上升" = "#2E86AB", "下降" = "#C8553D")) + theme_classic(base_size = 14) + labs(x = NULL, y = "指标值", color = "变化方向") print(p_dir)这里要注意一个细节:direction信息是从宽表计算得到的,所以需要用left_join回填到长表的每一行,而不是直接在绘图语句里写ifelse(condition == "after", value - ..., ...)——那样before行和after行会拿到不同的方向定义,连线层的分组会乱掉。
5.2 添加配对显著性标记
图形画好之后,很多人都会顺手把配对检验的p值标在图上方。用ggpubr包可以快速做到,但一定要记得加paired = TRUE。
library(ggpubr) p_dir + stat_compare_means( comparisons = list(c("before", "after")), method = "wilcox.test", paired = TRUE, label = "p.signif" )如果你不想为了一张图引入整个ggpubr,也可以手动加一个annotate图层,把检验结果用文本写上去。
wilcox_p <- wilcox.test(df_wide$before, df_wide$after, paired = TRUE)$p.value p_dir + annotate( "text", x = 1.5, y = max(df_dir$value) + 2, label = paste0("p = ", formatC(wilcox_p, digits = 3, format = "g")), size = 4 ) + scale_y_continuous(expand = expansion(mult = c(0.05, 0.15)))手动标注的好处是格式完全可控,坏处是如果后期数据更新,p值标签不会自动刷新。看个人习惯,我通常先用ggpubr快速看效果,最后只用ggpubr并固定seed,保证最终出图字段正确。
5.3 样本量标注、主题与配色
箱线图底部标上每组的样本量,是临床和实验报告中常见的做法。可以用annotate直接把n标在x轴刻度下方。
n_labels <- df_dir %>% group_by(condition) %>% summarise(n = n()) p_dir + annotate( "text", x = c(1, 2), y = min(df_dir$value) - 1.5, label = paste0("n=", n_labels$n), size = 3.5 )主题方面,我比较推荐theme_classic()或theme_minimal(),因为它们去掉了大部分网格线,箱线图加连线本身信息量已经足够,多余网格反而干扰。配色上尽量选饱和度适中的颜色,箱体填充用浅灰色就好,真正需要强调的是连线和散点的颜色。
5.4 样本量很大时怎么保留可读性
当样本量超过50时,所有个体的连线都会叠成一团,即使偏移也救不回来。我的处理方法是分两层:箱线图用全部样本画,点线层只随机抽样一部分个体。
set.seed(7) sub_ids <- df_wide %>% sample_n(30) %>% pull(id) df_sub <- df_long %>% filter(id %in% sub_ids) p_big <- ggplot(df_long, aes(x = condition, y = value)) + geom_boxplot( width = 0.35, alpha = 0.3, fill = "grey90", outlier.shape = NA ) + geom_line( data = df_sub, aes(group = as.character(id)), color = "grey40", alpha = 0.4 ) + geom_point( data = df_sub, aes(color = condition), size = 1.5, alpha = 0.7, position = position_jitter(width = 0.05, seed = 123) ) print(p_big)这样箱线图的分布信息来自全部样本,点线只展示一个代表性的子集,既能表达个体趋势,又不至于糊成一团。抽样时注意用同一个seed,并且固定一个明确的子集规模,这比直接从长表里sample_rows安全得多——直接从长表抽样可能会抽到同一个ID的before行却没抽到after行,导致线段只有半截。
6. 我实际踩过的坑与图表自检方法
6.1 坑一:ID是整数时,连线会莫名其妙乱掉
我第一次用这种图是处理一批模拟数据,ID刚好是1到20,group直接用aes(group = id)。图形乍一看没问题,但放大之后发现有些线并不是连接同一个ID的两个点,而是从样本3的before接去了样本7的after。排查了很久才发现,数值ID在ggplot里被当作连续变量处理,分组行为完全不是我想象的那样。
从那以后我给自己定了一条规矩:所有用于group的ID列,在进入ggplot之前一律转成字符或因子,最好在建长表时就处理。
df_long$id <- as.character(df_long$id)6.2 坑二:两个时间点的行顺序错位,箱线图正常但连线全错
比ID类型更隐蔽的坑,是宽表转长表之前的行顺序错位。我踩过一次:数据来自两个Excel文件,一个放before,一个放after,我在合并时没有按ID做join,而是直接cbind拼在一起。结果箱线图完全正常,因为箱线图只关心列向量本身;但连线全部乱了,因为before行和after行的ID并不对应同一个个体。
这个坑之所以难发现,是因为图面上所有点、线、箱体都在正确范围里,看起来一切正常,只有连线的具体连接关系是错的。自检方法其实很简单:把ID标在图上。
p_base + geom_text( aes(label = id, y = value), size = 2.5, vjust = -0.6, position = position_jitter(width = 0.06, seed = 123) )如果图上的ID出现在错误的位置,或者连线两端不是同一个ID,一次就能看出来。我在正式出图前,一直保留这个带ID标注的debug版本,确认无误后再去掉标签。
6.3 坑三:箱线图离群点与散点层的三重叠加
这不算太隐蔽,但很多人第一次画的时候都会疑惑:为什么箱线图里明明隐藏了离群点,图中还是有孤零零的点悬在外面?其实那不是箱线图画的,而是我们在散点层自己画出来的原始数据点。如果把这些点也去掉,图确实干净了,但也就丢失了个体信息。
我的建议是保留散点,只隐藏箱线图离群值,既能看到全部样本,又不会重复描点。如果你确实不想显示个别极端个体的点,那也应该在数据清洗阶段给出合理理由,而不是单纯为了图好看把它藏起来。
6.4 坑四:重复运行同一段代码,点的位置每次都不一样
position_jitter每次运行都会生成新的随机数,如果你上午画了一张图截了图,下午重新运行一遍代码,点的位置已经悄悄变了。这在写报告、做PPT、给领导汇报时尤其尴尬——今天的图和昨天的图看起来像两份完全不同的数据。
解决方式在4.3节已经提过:把抖动作为数据列固定下来,或者使用position_jitter(seed = 123)这类固定种子的写法。但即使是固定seed,也只是让每一次内部随机过程一致,如果你改了数据顺序,结果仍然可能不同。最可控的方案始终是把坐标偏移独立成数据列。
6.5 一个通用的自检流程
最后的最后,我把我自己画配对连线箱线图之前的流程抄录一遍,当作一个检查清单。
- 用
table(df_long$id)确认每个ID出现次数为2。 - 用
pivot_wider把长表转回宽表,手动验证before和after的ID是否一一对应。 - 画一个最朴素的图:箱线图全部灰色,连线全部灰色,不加任何美化。
- 在图上临时加ID标签,随机抽5个ID,肉眼确认连线两端ID一致。
- 确认无误后再加颜色、抖动、显著性标记,最后去掉ID标签出正式图。
这套流程看起来繁琐,但它能挡住绝大多数“看起来没问题、实际上数据错配”的图。我一直认为,配对连线箱线图的价值不只是展示结果,更重要的是它逼着画图人去面对每一个个体、每一对数据,这本身就是对数据质量的一次审查。把这个审查过程养成习惯,比掌握多少画图技巧都重要。