ggplot2 geom_flat_violin雨云图分布曲线显示异常问题咨询
geom_flat_violin()绘制雨云图密度曲线异常收缩问题
问题现象
使用R语言开展数据可视化绘制雨云图(raincloud plot)时,发现geom_flat_violin()生成的密度曲线存在异常表现:
- density密度曲线在部分场景下对数据values取值异常敏感,分布曲线形状和数据取值存在本不应出现的关联,暂未找到让二者恢复独立的方法
- 已排查到明确规律:曲线会依据数据中的最低值出现收缩,且收缩效应会作用于低值所在的整个分面面板,而非仅影响包含该低值的子分组
前置说明
- 雨云图官方包未收录至CRAN,测试时分别验证了作者公开的源码、第三方开源的
geom_flat_violin实现版本,两个版本均可稳定复现问题 - 曾尝试
ggridges::geom_density_ridges()、{ggdist}等其他可绘制雨云图的方案,但这些方案默认对平滑参数、图形形状的控制灵活度不足,无法满足定制化绘图需求
可复现代码
library(reshape2) library(ggplot2) source("https://gist.githubusercontent.com/benmarwick/2a1bb0133ff568cbe28d/raw/fb53bd97121f7f9ce947837ef1a4c65a73bffb3f/geom_flat_violin.R") # 加载数据并转换为长格式 data(iris) miris <- melt(iris,id.vars = "Species", measure.vars = colnames(iris)[1:4], variable.name = "measurement") ## 1. 直接使用原始数据绘图,4个分面面板中有2个的曲线出现水平方向的"压扁"收缩 ggplot(miris, aes(x = Species, y = value, fill = Species)) + geom_flat_violin(position = position_nudge(x = .15, y = 0)) + facet_wrap(~measurement) ## 2. 调整最小值所在分组的取值后,对应分面的所有曲线都恢复正常(调整其他分组无法解决问题) airis <- miris # 提取需要调整的数据索引 inds <- intersect(which(airis$Species == "setosa"), which(airis$measurement == "Petal.Width")) # 给该组赋值更大的数值 airis$value[inds] <- rnorm(length(inds), 3, 0.5) ggplot(airis, aes(x = Species, y = value, fill = Species)) + geom_flat_violin(position = position_nudge(x = .15, y = 0)) + facet_wrap(~measurement) ## 第二张图中"Petal.Width"分面下所有物种的分布曲线都被放大,但实际仅修改了setosa组的Petal.Width取值
效果对比

图中左侧为原始数据直接绘图效果,右侧为调整小值后的绘图效果,可直观看到仅修改setosa组Petal.Width的取值后,Petal.Width分面下所有物种的分布曲线都出现了放大。
问题原因
这个异常的核心是第三方开源版geom_flat_violin()的密度缩放逻辑作用范围错误:
- 原生ggplot2的
geom_violin()会为每个分组单独计算核密度估计,再将每个分组内的密度值归一化到0-1区间映射小提琴宽度,因此不同分组的分布形状不会互相干扰 - 广泛流传的第三方
geom_flat_violin源码为了实现单侧扁平的视觉效果,改写了密度绘制逻辑,但遗漏了分组归一化步骤:它会先计算当前分面面板下所有数据的密度最大值,再用这个全局最大值统一缩放该分面下所有分组的曲线宽度 - 当分面内存在一组取值范围极窄、密度峰值极高的数据时(比如案例中setosa的Petal.Width取值集中在0.1-0.6的窄区间),全局密度最大值会被拉得非常高,其他取值范围更宽、密度峰值更低的分组,曲线宽度就会被整体压缩得很窄,呈现出"压扁收缩"的视觉效果。这也是为什么仅修改最小值所在组的取值,整个分面的所有曲线都会变宽——本质是全局密度最大值被降低了。
可行修复方案
方案1:修改geom_flat_violin源码,添加分组内宽度归一化(最推荐)
找到源码中核密度计算完成、生成绘图数据的段落,在密度值density列计算完成后,添加按分面+分组维度的归一化逻辑,再用归一化后的数值映射宽度:
# 在密度计算代码后新增如下步骤 data <- data |> dplyr::group_by(PANEL, group) |> dplyr::mutate(scaled_density = density / max(density)) |> dplyr::ungroup() # 将后续绘制逻辑中原来映射宽度的density字段替换为scaled_density
修改后每个分组的扁平小提琴最大宽度会统一,不会再出现同个分面下部分分组曲线被压扁的问题,同时可以完整保留对平滑带宽、轮廓形状等所有参数的自定义控制权。
方案2:调用函数时手动指定参数规避全局缩放
如果不想修改源码,可以在调用geom_flat_violin()时传入固定带宽参数,同时开启分组宽度归一化(部分高版本源码支持该参数):
ggplot(miris, aes(x = Species, y = value, fill = Species)) + geom_flat_violin( position = position_nudge(x = .15, y = 0), bw = 0.2, # 根据自身数据的取值范围调整固定带宽,避免窄分布组密度峰值异常偏高 scale = "width" # 强制按分组归一化宽度 ) + facet_wrap(~measurement)
方案3:换用维护更完善的ggdist包实现相同效果
ggdist包目前处于活跃维护状态,其中的stat_slab()几何对象可以完全替代geom_flat_violin()的单侧扁平小提琴效果,默认按分组归一化宽度,且支持所有平滑参数、形状参数的自定义调整:
library(ggdist) ggplot(miris, aes(x = Species, y = value, fill = Species)) + stat_slab( position = position_nudge(x = .15, y = 0), side = "left", # 设置为单侧绘制,和flat_violin视觉效果完全一致 normalize = "groups", # 按分组归一化宽度 adjust = 1 # 平滑度调整参数,用法和geom_density的adjust参数完全一致 ) + facet_wrap(~measurement)
内容的提问来源于stack exchange,提问作者MARO
相关产品推荐
相关产品推荐

