R语言小提琴图进阶:从ggplot2到tidybayes的分布可视化实战

发布时间:2026/10/14 1:10:58
R语言小提琴图进阶:从ggplot2到tidybayes的分布可视化实战
1. 从能画出来到画得对小提琴图在真实数据探索中的定位很多人第一次接触小提琴图是因为它看起来比箱线图高级——两侧对称的密度曲线中间嵌一个箱体既能看到分布形状又能看到中位数和四分位数。但真正在项目里用过几轮之后你会发现小提琴图最大的价值不在于好看而在于它能暴露那些被均值和中位数掩盖掉的多峰分布、偏态分布和异常堆积。我见过太多分析报告用柱状图加误差棒展示分组差异结果两组均值几乎一样但一组是双峰、一组是单峰结论完全相反。这种时候小提琴图就是那个照妖镜。这篇内容面向的是已经会用R语言画基础图形、但对分布可视化还停留在geom_boxplot()阶段的读者。我会围绕ggplot2和tidybayes这两个包把小提琴图从数据准备、几何对象选择、密度缩放、分组排布到贝叶斯模型后验分布的批量可视化完整走一遍。重点不是罗列函数参数而是解释每一个选择背后的理由——为什么用scalewidth而不是默认的scalearea为什么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是矢量格式但如果是导出PNG300dpi是印刷的最低要求。另外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 areastat_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()配合正则表达式提取。这些坑的共同点是文档里不会专门写只有实际画过十几张图之后才会遇到。我的建议是每次画完图之后把代码和图形一起保存下来标注数据特征样本量、分组数、是否有极端值下次遇到类似数据时可以直接参考。