箱线图 + 散点抖动:展示分布
跟完这篇,您能画出一张既概括分布、又不掩盖真实数据的分组比较图:读懂箱线图的每一条线在说什么、为什么要在箱子上叠一层散点抖动、显著性标注怎么加才不误导,以及什么时候该换成小提琴图。
比较几组数值(如某基因在肿瘤组与正常组的表达、几种处理下的某项指标),最常见的一张图就是箱线图(boxplot)。它用五个数把一组数据的分布压成一个箱子,几组并排,高低一目了然。但箱线图有个天生的毛病:它把每一个样本都藏了起来。所以现在的通行做法是——箱线图打底,散点抖动叠上去,既要概括、也要诚实。
箱线图的每一部分在说什么
箱线图画的是「五数概括」(five-number summary)。看懂下面这几个部件,才不会读错图。
| 部件 | 对应的量 | 含义 |
|---|---|---|
| 箱子中间的横线 | 中位数(median,Q2) | 一半数据在它上、一半在它下 |
| 箱子的下边 | 下四分位数(Q1,25% 分位) | 有 25% 的数据比它小 |
| 箱子的上边 | 上四分位数(Q3,75% 分位) | 有 75% 的数据比它小 |
| 箱子的高度 | 四分位距(IQR = Q3 − Q1) | 中间 50% 数据的跨度,衡量离散程度 |
| 上下的「须」 | 延伸到 1.5 × IQR 内最远的点 | 覆盖大部分「正常范围」的数据 |
| 须之外的点 | 离群点(outlier) | 距箱子超过 1.5 × IQR 的观测 |
两点容易被忽略,先说清楚:
- 箱线图里的横线是中位数,不是平均数。 中位数对离群值不敏感,所以箱线图天然抗极端值。如果您想强调平均数,得另外用点单独标出来,别把中位线错读成均值。
- 「须」的长度不是最大最小值。 ggplot2、以及绝大多数统计软件默认用的是 Tukey 定义:须只延伸到「距箱子不超过 1.5 × IQR」范围内最远的那个真实数据点,再远的单独画成离群点。所以须的端点几乎总是落在某个实际观测上,而不是理论边界。
「1.5 × IQR」是判定离群点的常用惯例,不是铁律。它来自 Tukey 的经典建议,被大多数软件设成默认,但离群与否终究要结合专业判断——一个「离群点」可能是录入错误,也可能是最有意思的真实个体,别一律删掉。
先画一张标准箱线图
假设您有一张长表 df:一列是分组 group(因子),一列是数值 value。这是绘图最顺手的格式,如果您手上是宽表,先转成长表(tidyr::pivot_longer)。
library(ggplot2)
ggplot(df, aes(x = group, y = value)) +
geom_boxplot(outlier.shape = NA) + # 先把离群点关掉,下一步用散点统一显示
labs(x = NULL, y = "表达量") +
theme_bw()
这里有个关键的小动作:outlier.shape = NA。因为下一步要叠散点,所有点都会由散点层画出来;若不关掉,箱线图自带的离群点会和散点重叠,同一个观测被画两遍。这是叠图时最常见的一个小坑。
为什么要叠上散点抖动
箱线图把一组数据压成五个数,代价是把每个样本藏了起来。而藏起来的东西,往往正是要命的:
- 看不出样本量。 一个箱子背后是 5 个样本还是 500 个,画出来一模一样。但 n=5 的比较和 n=500 的比较,可信度天差地别。
- 看不出真实形状。 一组「双峰」数据(其实是两个亚群)和一组集中在中间的数据,可能画出几乎相同的箱子。箱线图假设数据是单峰的,一旦不是,它就会骗人。
- 看不出数据是不是「空的」。 极端情况下,箱子中间大片区域一个点都没有,全靠上下两小撮撑着——箱线图完全看不出来。
解决办法很简单:把每一个原始数据点画上去。这样读者能一眼看到 n 有多大、点是怎么散布的、有没有可疑的空洞或聚堆。这不是装饰,而是一种诚实——让图承认「我背后到底有多少证据」。
在样本量不大(比如每组几个到几十个)的科研数据里,「箱线图 + 散点」几乎应当是默认做法。审稿人越来越倾向于要求看到原始点,尤其是当每组样本很少时。
加上散点抖动
直接把点画上去会有个问题:同一组的点 x 坐标相同,会叠成一条竖线,重合的点根本数不清。抖动(jitter) 就是给每个点的水平位置加一点随机偏移,把它们摊开。
library(ggplot2)
ggplot(df, aes(x = group, y = value)) +
geom_boxplot(outlier.shape = NA, width = 0.5) +
geom_jitter(width = 0.15, height = 0, # 只横向抖动,纵向绝不能抖
alpha = 0.6, size = 1.8) +
labs(x = NULL, y = "表达量") +
theme_bw()
几个参数值得逐一说明:
| 参数 | 作用 | 建议 |
|---|---|---|
width | 横向抖动的幅度 | 0.1~0.2 常够用,别宽到两组的点混在一起 |
height = 0 | 纵向抖动幅度 | 必须设为 0 |
alpha | 点的透明度 | 点多时调低(如 0.4~0.6),看得出密度 |
size | 点的大小 | 点多就调小,避免糊成一团 |
这里最重要的一条规则:纵向绝对不能抖动(height = 0)。纵轴是您真正的数值,一旦纵向抖动,点的高低就不再是它的真实取值,图直接读错。横向抖动只是为了摊开重叠,不影响任何数值——这条线千万别踩。
如果点特别多、抖动后还是糊成一片,可以换 ggbeeswarm 包的「蜂群图」(beeswarm),它按密度把点整齐地排开、不重叠,比纯随机抖动更好看也更好读。
library(ggbeeswarm)
ggplot(df, aes(x = group, y = value)) +
geom_boxplot(outlier.shape = NA, width = 0.5) +
geom_quasirandom(width = 0.2, alpha = 0.6) + # 按密度铺开,不随机重叠
theme_bw()
怎么加显著性标注
组间「差没差」不能靠眼睛看箱子高低,得配一个统计检验,再把结果标在图上。ggpubr 包的 stat_compare_means() 能把检验和标注一步做完。
两组比较,指定要比的那一对:
library(ggpubr)
ggplot(df, aes(x = group, y = value)) +
geom_boxplot(outlier.shape = NA) +
geom_jitter(width = 0.15, height = 0, alpha = 0.6) +
stat_compare_means(
comparisons = list(c("Tumor", "Normal")),
method = "wilcox.test" # 明确写清用什么检验
) +
theme_bw()
多组比较,先给一个整体检验,再列出要两两比的组合:
my_comparisons <- list(c("A", "B"), c("A", "C"), c("B", "C"))
ggplot(df, aes(x = group, y = value)) +
geom_boxplot(outlier.shape = NA) +
geom_jitter(width = 0.15, height = 0, alpha = 0.5) +
stat_compare_means(method = "kruskal.test") + # 整体检验(放在图上方)
stat_compare_means(comparisons = my_comparisons,
method = "wilcox.test") + # 两两比较
theme_bw()
标注要标得诚实,有三条红线:
- 检验方法要和数据匹配,别用默认糊弄。 两组正态、方差齐用
t.test;不满足或样本少、偏态,用非参数的wilcox.test(Mann-Whitney U)。多组对应anova与kruskal.test。stat_compare_means有默认方法,但请显式写出method =,别让读者猜。到底怎么选,见 怎么选统计检验。 - 多次两两比较要校正 p 值。 三组就有三对、四组有六对,比得越多,光靠运气「碰」出显著的概率越高。做多重比较时应对 p 值做校正(如 Benjamini-Hochberg、Bonferroni),别把一堆没校正的
*直接贴上去。原理见 多重检验校正。 - 星号不是幅度。
***只说明「差异更不像是偶然」,不代表差得更多。一个统计上极显著、但实际只差一点点的结果,在生物学上可能毫无意义。星号旁边,最好让读者也能从散点看出实际差多大。
图上的
*到底代表多小的 p 值,不同期刊约定不完全一样(常见的一种是*对应p<0.05、**对应p<0.01、***对应p<0.001)。务必在图注里写清您的对应关系和所用检验,别让读者靠猜。
什么时候改用小提琴图
散点叠箱线图很好,但当每组样本非常多(成百上千)时,点会多到抖动也摊不开,糊成一坨。这时候小提琴图(violin plot)更合适。
小提琴图画的是数据的密度分布:哪个数值区间点多,小提琴就在那里鼓得胖;哪里点少就瘦。它比箱线图多告诉您一件关键的事——分布的形状。前面说箱线图会把「双峰」藏起来,而小提琴图会直接鼓出两个包,一眼看穿。
常见做法是把三者叠起来,各取所长:
library(ggplot2)
ggplot(df, aes(x = group, y = value)) +
geom_violin(trim = FALSE, fill = "grey90") + # 外层:分布形状
geom_boxplot(width = 0.15, outlier.shape = NA) + # 内层:五数概括
geom_jitter(width = 0.08, height = 0, alpha = 0.3) + # 点:真实数据(量大时可省)
theme_bw()
怎么选,一张表说清:
| 情况 | 更合适的选择 | 原因 |
|---|---|---|
| 每组样本少(几个~几十个) | 箱线图 + 散点抖动 | 点画得下,直接给读者看原始数据最诚实 |
| 每组样本很多(几百以上) | 小提琴图(可内嵌小箱线) | 点已摊不开,密度曲线更清楚 |
| 怀疑分布是双峰 / 强偏态 | 小提琴图 | 能看出形状,箱线图会掩盖 |
| 只想快速比几组高低 | 箱线图 | 最简洁、最好读 |
一个反过来的提醒:样本太少时别用小提琴图。 小提琴的形状是由核密度估计「平滑」出来的,只有几个点时,它会把噪声脑补成一个圆润好看、其实并不存在的形状,反而骗人。少数据老老实实用箱线加散点。
geom_violin默认trim = TRUE,会把小提琴在数据的最小/最大值处截断;设trim = FALSE则让密度曲线自然延伸出去。两种都常见,按您想强调的是「实际范围」还是「平滑形状」来选即可。
常见错误
| 错误 | 后果 | 怎么避免 |
|---|---|---|
| 散点纵向也抖动 | 点的高低不再是真实数值,图读错 | geom_jitter 里 height = 0 |
| 忘了关箱线图自带离群点 | 离群点被画两遍、重叠 | geom_boxplot(outlier.shape = NA) |
| 把中位线当平均数读 | 结论说偏 | 记牢箱线图中间是中位数;要均值另标 |
| 多组两两比较不校正 p 值 | 假阳性,* 贴多了 | 做多重检验校正,见相应教程 |
图注不写检验方法和 * 含义 | 读者无法判断可信度 | 图注写清所用检验与星号对应的 p 值 |
| 样本极少还画小提琴 | 平滑出并不存在的形状 | 少数据用箱线 + 散点 |
在平台上跑一遍
不想写 R 代码也能得到这张图:在 百沐一下 传入一张含分组列和数值列的表,用一句话说清「按组画箱线图、叠散点抖动、Tumor 与 Normal 两组做 Wilcoxon 检验并标显著性」,就会给您一张出版级图,连同可复现的 R 代码一起交付,检验方法、配色、要不要换小提琴都能对话式再调。出图流程见 智能绘图使用指南,选检验可参考 智能统计。
相关
- 怎么选统计检验 —— 两组、多组、配对分别该用什么检验
- 多重检验校正 —— 多次两两比较为什么、怎么校正 p 值
- t 检验怎么做 —— 两组均值比较的标准流程与前提
- 火山图:差异基因可视化 —— 换个场景:成千上万个基因一起比时怎么画