1. 从"能画出来"到"画得对":小提琴图在真实数据探索中的定位
很多人第一次接触小提琴图,是因为它看起来比箱线图"高级"——两侧对称的密度曲线,中间嵌一个箱体,既能看到分布形状,又能看到中位数和四分位数。但真正在项目里用过几轮之后你会发现,小提琴图最大的价值不在于好看,而在于它能暴露那些被均值和中位数掩盖掉的多峰分布、偏态分布和异常堆积。我见过太多分析报告,用柱状图加误差棒展示分组差异,结果两组均值几乎一样,但一组是双峰、一组是单峰,结论完全相反。这种时候,小提琴图就是那个"照妖镜"。
这篇内容面向的是已经会用R语言画基础图形、但对分布可视化还停留在geom_boxplot()阶段的读者。我会围绕ggplot2和tidybayes这两个包,把小提琴图从数据准备、几何对象选择、密度缩放、分组排布,到贝叶斯模型后验分布的批量可视化,完整走一遍。重点不是罗列函数参数,而是解释每一个选择背后的理由——为什么用scale="width"而不是默认的scale="area",为什么tidybayes的stat_halfeye()在某些场景下比geom_violin()更合适,以及当分组数量超过五个时怎么避免图形变成一团糊。
需要提前说明的是,tidybayes本身并不是专门画小提琴图的包,它的核心能力是把贝叶斯模型的后验分布整理成"长格式"数据框,然后借助ggplot2的几何对象来展示。所以这篇文章实际上有两条线:一条是纯ggplot2的小提琴图进阶技巧,另一条是tidybayes如何把模型输出接入这套可视化体系。两条线会在"分布比较"这个共同目标下汇合。
2. 数据格式与密度估计:小提琴图的地基怎么打
2.1 长格式数据为什么是绕不开的前提
ggplot2的图层语法决定了它最舒服的数据形态是"长格式"——每一行是一个观测,每一列是一个变量,分组信息单独成一列。如果你手头是宽格式数据,比如三列分别是"对照组""处理组A""处理组B"的测量值,那第一步必须用tidyr::pivot_longer()转成长格式。这不是可选项,而是必须项,因为geom_violin()的x轴映射需要一个分类变量来区分组别。
library(tidyverse) # 假设原始数据是三列宽格式 wide_data <- tibble( control = rnorm(200, mean = 10, sd = 2), treat_a = c(rnorm(120, mean = 12, sd = 1.5), rnorm(80, mean = 8, sd = 1)), treat_b = rnorm(200, mean = 10.5, sd = 3) ) long_data <- wide_data %>% pivot_longer( cols = everything(), names_to = "group", values_to = "value" ) long_data转换之后,group列有三个水平,value列是数值。这个结构直接对应aes(x = group, y = value)。我踩过的一个坑是:有些人为了省事,直接在宽格式上用geom_violin()分别画三次,然后用scale_fill_manual()手动配色。这样做在图形上看起来没问题,但一旦要做分面、要加显著性标记、要统一调整密度带宽,就会非常痛苦。长格式是一次性投入,后面所有操作都顺。
2.2 密度估计的带宽:小提琴形状的真正控制杆
小提琴图的轮廓来自核密度估计(KDE)。geom_violin()内部调用的是ggplot2自带的密度计算,默认带宽由bw参数控制,不指定时使用"nrd0"规则自动选择。这个自动选择在样本量差异大的分组之间会出问题:样本量小的组密度曲线会过于平滑,样本量大的组会过于锯齿。更麻烦的是,当两组样本量相差三倍以上时,默认带宽会让两组的小提琴宽度不可比。
我的做法是显式指定bw,并且让所有组共用同一个值。取值可以参考bw.nrd0()对合并数据的计算结果,然后在此基础上微调。
# 计算合并数据的参考带宽 ref_bw <- bw.nrd0(long_data$value) ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(bw = ref_bw, trim = FALSE) + theme_minimal()trim = FALSE这个参数值得单独说。默认trim = TRUE会把密度曲线截断在观测数据的最大值和最小值处,这样小提琴的尾部不会超出实际数据范围。但如果你要比较的是理论分布或者模型后验分布,trim = FALSE更合适,因为它保留了密度估计在尾部的外推。实测中,对于样本量小于50的分组,trim = TRUE会让小提琴看起来"被削平了头尾",视觉上损失了分布信息。
2.3 密度缩放:area和width的选择逻辑
geom_violin()的scale参数有三个选项:"area"(默认)、"count"和"width"。这是小提琴图最容易混淆的地方,我用一个表格说清楚。
| scale取值 | 含义 | 适用场景 | 视觉陷阱 |
|---|---|---|---|
"area" | 所有小提琴的最大宽度相同,面积与样本量成正比 | 样本量差异不大时 | 样本量小的组看起来"瘦高",容易被误读为分布更集中 |
"count" | 面积与样本量成正比,宽度也随样本量缩放 | 强调样本量差异 | 样本量小的组几乎变成一条线 |
"width" | 所有小提琴的最大宽度相同,面积不反映样本量 | 样本量差异大,关注分布形状 | 会掩盖样本量信息,需在图中标注n |
我个人的默认选择是scale = "width",然后在图下方用annotate()或者直接在x轴标签里标注每组的样本量。原因很简单:当你在比较五六个分组时,样本量从30到300不等,用"area"会让小样本组的小提琴细得看不清内部箱线,而"width"保证每个组的分布形状都能被平等地观察。代价是读者可能忽略样本量差异,所以标注n是必须的补偿措施。
ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(scale = "width", bw = ref_bw, trim = FALSE) + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + labs(x = NULL, y = "测量值") + theme_minimal() + theme(legend.position = "none")这里嵌了一个窄箱线图,宽度设为0.1,填充白色,隐藏异常点。箱线图提供中位数和四分位距的精确读数,小提琴提供分布形状,两者叠加是分布可视化的标准组合。注意outlier.shape = NA是必要的,否则箱线图的异常点会和小提琴的尾部重叠,视觉上很乱。
3. 分组排布与配色:当小提琴超过五个时怎么救
3.1 水平排布与坐标翻转的取舍
分组数量超过四个之后,垂直排列的小提琴会变得又窄又高,密度曲线的细节被压缩。这时候有两个选择:一是用coord_flip()把x轴和y轴对调,让分组沿y轴排列;二是保持垂直但增加图形宽度。我倾向于前者,因为人眼对水平方向的密度变化更敏感,而且长分组名在y轴上更容易完整显示,不需要旋转角度。
ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(scale = "width", bw = ref_bw) + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + coord_flip() + theme_minimal() + theme(legend.position = "none")coord_flip()之后,原本的x轴分组变成y轴,y轴数值变成x轴。注意geom_boxplot()的width参数在翻转后控制的是箱体的高度,不是宽度,这个细节在调整时需要重新试。
3.2 分组顺序的手动控制
默认情况下,ggplot2按字母顺序排列分组。但分析中经常需要按中位数或者按实验设计顺序排列。用forcats::fct_reorder()可以按中位数重排,用factor(levels = ...)可以手动指定顺序。
# 按中位数升序排列 long_data <- long_data %>% mutate(group = fct_reorder(group, value, .fun = median)) # 或者手动指定 long_data <- long_data %>% mutate(group = factor(group, levels = c("control", "treat_a", "treat_b")))我通常会在图注里说明排序依据,因为读者看到分组顺序变化时会下意识寻找原因。如果是按中位数排序,图注写"分组按中位数升序排列";如果是按实验设计排序,图注写"分组按实验条件顺序排列"。这个习惯是从审稿意见里学来的——有审稿人专门问过为什么对照组排在中间。
3.3 配色方案:离散色板和透明度调节
ggplot2默认的离散色板在分组超过五个时区分度不够。我常用RColorBrewer的"Set2"或"Paired"色板,前者饱和度低、适合打印,后者对比度高、适合屏幕展示。如果分组超过八个,任何离散色板都会吃力,这时候应该考虑分面(facet_wrap)而不是硬塞在一张图里。
ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(scale = "width", bw = ref_bw, alpha = 0.7) + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + scale_fill_brewer(palette = "Set2") + coord_flip() + theme_minimal() + theme(legend.position = "none")alpha = 0.7让小提琴半透明,这样当两组的小提琴有重叠时(在position = "identity"时才会重叠,默认position = "dodge"不会),可以看到重叠区域。不过大多数情况下小提琴图用默认的dodge位置就够了,重叠场景更多出现在tidybayes的后验分布对比中。
4. tidybayes接入:把贝叶斯模型的后验分布画成小提琴
4.1 tidybayes解决的是什么问题
tidybayes的核心函数是spread_draws()和gather_draws(),它们把贝叶斯模型(比如brms、rstanarm、rstan的输出)的后验抽样结果整理成"每一行是一个抽样、每一列是一个参数"的长格式数据框。整理之后,你就可以用ggplot2的任何几何对象来画后验分布,包括小提琴图。
为什么不用geom_violin()直接画后验?因为后验分布通常有几千到几万个抽样点,geom_violin()的密度估计在这种数据量下计算量不小,而且默认带宽会非常小,小提琴看起来会像锯齿。tidybayes提供了stat_halfeye()和stat_dotsinterval()等统计变换,它们内部对密度估计做了优化,并且默认使用scale = "width"的逻辑,更适合后验分布的可视化。
library(tidybayes) library(brms) # 假设已经拟合了一个贝叶斯模型 fit # fit <- brm(value ~ group, data = long_data) # 提取后验抽样 posterior_draws <- fit %>% spread_draws(b_Intercept, b_grouptreat_a, b_grouptreat_b) %>% pivot_longer( cols = starts_with("b_"), names_to = "parameter", values_to = "posterior_value" ) posterior_draws4.2 stat_halfeye与geom_violin的对比
stat_halfeye()画的是"半眼图"——一半是小提琴(密度曲线),一半是区间条(默认是95%和50%最高密度区间)。它和geom_violin()的区别在于:
stat_halfeye()默认scale = "width",所有参数的最大宽度一致,适合比较不同参数的后验分布宽度。stat_halfeye()自带区间条,不需要额外叠加箱线图。stat_halfeye()的密度估计使用ggdist包的优化实现,对大数据量更友好。
ggplot(posterior_draws, aes(x = posterior_value, y = parameter, fill = parameter)) + stat_halfeye(.width = c(0.5, 0.95), point_interval = "median_hdi") + scale_fill_brewer(palette = "Set2") + labs(x = "后验值", y = NULL) + theme_minimal() + theme(legend.position = "none").width = c(0.5, 0.95)指定了两个区间:50%和95%最高密度区间(HDI)。point_interval = "median_hdi"指定用中位数作为点估计,用HDI作为区间。HDI和等尾区间的区别在于,HDI是包含概率密度最高的最短区间,对于偏态后验分布,HDI比等尾区间更有意义。
4.3 分组后验分布的批量小提琴
当模型有多个分组参数时,比如b_grouptreat_a和b_grouptreat_b,可以直接用stat_halfeye()批量画出来。但如果要比较的是每个组的预测值而不是参数本身,需要用add_epred_draws()或者add_fitted_draws()。
# 提取每个组的预测值后验 epred_draws <- long_data %>% distinct(group) %>% add_epred_draws(fit) ggplot(epred_draws, aes(x = .epred, y = group, fill = group)) + stat_halfeye(.width = c(0.5, 0.95)) + scale_fill_brewer(palette = "Set2") + labs(x = "预测值后验分布", y = NULL) + theme_minimal() + theme(legend.position = "none")这里add_epred_draws()返回的.epred列是每个组在每个抽样下的预测值。stat_halfeye()会自动按group分组计算密度。实测中,如果后验抽样超过4000个,stat_halfeye()的渲染速度比geom_violin()快不少,因为ggdist内部用了更高效的密度计算。
5. 那些文档里不写的实操细节
5.1 样本量极小时的小提琴图陷阱
当某组样本量小于10时,核密度估计基本没有意义——带宽会自动变得很大,小提琴看起来像一个胖椭圆,完全丢失分布形状。这种情况下,我的做法是放弃小提琴,改用geom_dotplot()或者stat_dots()直接画每个观测点。如果一定要保留小提琴的视觉框架,可以把bw设得很小,同时叠加geom_jitter()显示原始点。
small_group_data <- long_data %>% filter(group == "treat_a") ggplot(small_group_data, aes(x = group, y = value)) + geom_violin(bw = 0.5, scale = "width", alpha = 0.5) + geom_jitter(width = 0.1, size = 1.5, alpha = 0.6) + theme_minimal()bw = 0.5是手动指定的极小带宽,具体值取决于数据的量纲。判断标准是:如果小提琴的轮廓出现了明显的锯齿或者多个小峰,说明带宽太小;如果小提琴变成了一个光滑的椭圆,说明带宽太大。对于样本量小于10的数据,我通常直接建议不用小提琴。
5.2 密度曲线超出数据范围的处理
trim = FALSE时,密度曲线会向数据范围之外延伸。如果数据的物理范围有硬边界(比如浓度不能为负),延伸出来的部分会造成误导。这时候有两个选择:一是用trim = TRUE截断,二是用coord_cartesian()手动限制显示范围。
ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(trim = FALSE, scale = "width") + coord_cartesian(ylim = c(0, NA)) + theme_minimal()coord_cartesian()和scale_y_continuous(limits = ...)的区别在于,前者是"缩放"显示范围,不删除数据;后者是"截断"数据,超出范围的点会被删除并可能触发警告。对于小提琴图,用coord_cartesian()更安全,因为密度曲线是计算出来的,不是原始数据点。
5.3 图形导出时的字体和尺寸问题
小提琴图在RStudio的绘图窗口里看起来正常,导出PDF后经常出现字体变小、线条变细的问题。这是因为RStudio的预览窗口有自动缩放,而导出时的实际尺寸取决于ggsave()的width和height参数。我的习惯是:先确定目标期刊或报告的单栏宽度(通常是85mm或170mm),然后按这个宽度导出,字体大小在theme()里显式指定。
ggsave( "violin_plot.pdf", width = 170, height = 120, units = "mm", dpi = 300 )dpi = 300对PDF导出其实不起作用,PDF是矢量格式,但如果是导出PNG,300dpi是印刷的最低要求。另外,theme_minimal()的默认字体是sans,如果目标期刊要求serif字体,需要在theme()里加text = element_text(family = "serif")。
5.4 与小提琴图搭配的统计标记
在小提琴图上加显著性标记是常见需求。我的做法是用ggsignif包或者手动用annotate()加线段和星号。但要注意,小提琴图展示的是分布,显著性检验比较的是均值或中位数,两者不是一回事。如果要在图注里写"* p < 0.05",必须说明检验方法(比如Wilcoxon秩和检验)和比较的是哪个统计量。
library(ggsignif) ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(scale = "width") + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + geom_signif( comparisons = list(c("control", "treat_a"), c("control", "treat_b")), map_signif_level = TRUE, y_position = c(18, 20) ) + theme_minimal() + theme(legend.position = "none")y_position需要根据数据的实际范围手动调整,否则显著性标记会跑到图形外面。我通常先画一遍不带标记的图,看y轴范围,然后在此基础上加2到3个单位作为标记位置。
6. 从单张图到批量报告:把代码封装成可复用函数
6.1 为什么需要封装
如果一份报告里有十几个类似的分布比较图,每个图都复制粘贴一遍ggplot()代码,不仅冗长,而且一旦要调整配色或主题,需要改十几个地方。封装成函数是更可持续的做法。函数的核心参数包括:数据框、分组列名、数值列名、是否翻转坐标、配色方案。
plot_violin <- function(data, group_col, value_col, flip = TRUE, palette = "Set2") { p <- ggplot(data, aes(x = .data[[group_col]], y = .data[[value_col]], fill = .data[[group_col]])) + geom_violin(scale = "width", trim = FALSE, alpha = 0.7) + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + scale_fill_brewer(palette = palette) + labs(x = NULL, y = value_col) + theme_minimal() + theme(legend.position = "none") if (flip) { p <- p + coord_flip() } p } # 使用 plot_violin(long_data, "group", "value").data[[group_col]]是rlang包的代词语法,用于在函数内部引用外部传入的列名。这是ggplot2编程的标准做法,比aes_string()更安全,后者在新版本中已经被弃用。
6.2 批量出图与分面策略
如果分组变量有多个水平,而且每个水平下的数据量足够,用facet_wrap()分面比堆在一张图里更清晰。分面的优势是每个面板可以独立缩放y轴(scales = "free_y"),避免某个组的极端值压缩其他组的显示。
ggplot(long_data, aes(x = group, y = value, fill = group)) + geom_violin(scale = "width") + geom_boxplot(width = 0.1, fill = "white", outlier.shape = NA) + facet_wrap(~ group, scales = "free_y") + theme_minimal() + theme(legend.position = "none")但分面也有代价:当分组数量超过六个时,面板会变得很小,密度曲线的细节丢失。这时候更好的策略是只对重点分组做分面,其余分组用一张总图展示。
6.3 与tidybayes结合时的函数设计
当数据来源是贝叶斯模型的后验抽样时,函数需要额外处理.epred或.value列名。我的做法是让函数接受一个value_col参数,默认是".epred",这样同一个函数既能画原始数据,也能画后验预测。
plot_posterior_violin <- function(data, group_col, value_col = ".epred") { ggplot(data, aes(x = .data[[value_col]], y = .data[[group_col]], fill = .data[[group_col]])) + stat_halfeye(.width = c(0.5, 0.95), point_interval = "median_hdi") + scale_fill_brewer(palette = "Set2") + labs(x = "后验预测值", y = NULL) + theme_minimal() + theme(legend.position = "none") } # 使用 plot_posterior_violin(epred_draws, "group")这个函数和前面的plot_violin()共享了配色和主题逻辑,但几何对象不同。如果项目里同时有原始数据和后验数据,可以进一步抽象出一个统一的主题函数,把scale_fill_brewer()和theme_minimal()抽出来复用。
7. 几个我反复踩过的坑和对应的解法
第一个坑是geom_violin()和stat_halfeye()的scale参数默认值不同。geom_violin()默认scale = "area",stat_halfeye()默认scale = "width"。如果在一张图里混用两个几何对象,宽度会不一致。我的解法是显式指定scale = "width",两个都写清楚。
第二个坑是trim参数在stat_halfeye()里叫trim,但默认值是TRUE,而且它的行为和geom_violin()的trim不完全一样。stat_halfeye()的trim = TRUE会把密度曲线截断在数据的最大值和最小值处,但区间条不受影响。如果后验分布有明显的长尾,trim = FALSE更能展示尾部特征。
第三个坑是分组数量多时position = "dodge"的宽度控制。geom_violin()的width参数控制的是每个小提琴的最大宽度,但在dodge模式下,实际宽度还会受到分组数量的影响。分组超过四个时,需要把width从默认的0.9降到0.7左右,否则小提琴之间会重叠。
第四个坑是coord_flip()之后geom_signif()的坐标映射会乱。ggsignif包的y_position参数在翻转后对应的是x轴位置,需要重新调整。我的做法是尽量避免在翻转后的图上加显著性标记,而是把显著性检验结果写在图注里,或者用表格单独呈现。
第五个坑是tidybayes的spread_draws()在参数名包含特殊字符时(比如b_grouptreat_a里的下划线)会出问题。解法是用反引号把参数名括起来,或者用gather_draws()配合正则表达式提取。
这些坑的共同点是:文档里不会专门写,只有实际画过十几张图之后才会遇到。我的建议是,每次画完图之后,把代码和图形一起保存下来,标注数据特征(样本量、分组数、是否有极端值),下次遇到类似数据时可以直接参考。