Introduction
小提琴图回答的是分布的形状:一组数值在哪一段密集、哪一段稀疏,是一个峰还是两个。箱线图给的是五个数(中位数、四分位、须),形状被压掉了——两组数据可以有完全相同的中位数和四分位距,一组是单峰、另一组是两个峰挤出来的,画成箱线图长得一模一样。
所以它的场合是:怀疑组间差的不只是位置,还有形状。 只关心"哪组更高",箱线图更省版面;每组只有十来个观测,密度估计撑不起形状,直接画散点。
读的时候按这个顺序:
- 先看位置。 哪组整体偏高、哪组偏低——这部分箱线图也能回答。
- 再看宽度。 越宽说明那一段数值附近的样本越密。宽度怎么缩放由
scale决定,不是固定含义。 - 看是不是单峰。 出现两个鼓包,说明这一组内部可能还分着亚群;这是小提琴图唯一能给、箱线图给不了的信息。
- 回头数散点。 形状可不可信取决于有多少个点在撑着它。x 轴标签里的 n 和图上的点都是为这一步准备的。
- 最后才看星号。 它是补充,替代不了前面四步。
Example Data
小提琴图要的输入是:1 个分类变量 + 1 个数值变量,且每组要有足够多的观测。分类变量决定画几把小提琴,数值变量决定每把的形状。
iris 是 R 内置数据,150 朵花分属三个物种、每种 50 朵,这里画花萼长度。它本来就是长格式,一行一朵花,不需要重塑;要准备的只有两件事:把组的顺序钉死,把样本量写进坐标轴标签。
library(ggplot2)
library(ggpubr)
library(biopalette)
# iris 随 R 一起装好,不用下载。数据集页上那份 CSV 是同一批数值原样导出的
iris_data <- iris |>
transform(
# 显式写 levels:不写就按字母序排,改个组名或换份数据,
# 三把小提琴的左右次序会跟着变,而图上看不出发生过这件事
Species = factor(Species, levels = c("setosa", "versicolor", "virginica"))
)
# 样本量写进 x 轴标签 —— 读者判断"这个形状可不可信"要靠它,
# 而密度曲线本身不透露任何样本量信息
species_labels <- setNames(
paste0(levels(iris_data$Species), "\n(n=",
as.integer(table(iris_data$Species)), ")"),
levels(iris_data$Species)
)
Palettes
三个物种是三个无序类别,用 walter_white2 里的青、橄榄和橙,与 grouped scatter 的物种映射保持一致。只有三个真实分组就只取三色,不为了展示完整色板增加虚假类别。
同一种颜色要在三个尺度上都成立:小提琴是大面积半透明填充,散点是小面积原色,箱线是纸白底上的同色轮廓。这样既能看出色板铺成大色块时会不会太重,也能看出缩成小点后还辨不辨得清。深灰只用于点的边界和文字,不编码第四个变量。
小提琴的透明度固定 0.46。透明度把颜色和纸白背景混合,所以图上看到的浅色不是色板原值;原值由轮廓和散点保留。
# 取青、橄榄、橙;跳过第 3 色房车米(#D1BE9E),它太浅,
# 半透明填充压下去后和纸白背景分不开
walter_white2 <- get_palette("walter_white2", type = "qualitative")
species_colors <- setNames(
walter_white2[c(1, 2, 4)],
levels(iris_data$Species)
)
# 纸白底和墨色前景全站统一,写成常量,免得三张图各写各的十六进制
PAPER <- "#F4F3EE"
INK <- "#353531"
Recipe
| No. | Method | Input Data | Palettes |
|---|---|---|---|
| 1 | ggplot2 |
iris_data |
species_colors |
| 2 | ggpubr |
violin_plot |
— |
| 3 | ggstatsplot |
iris_data |
species_colors |
1 · ggplot2
三层各司其职:小提琴给形状、散点给原始观测、最后盖上的窄箱线给四分位和中位数。
顺序不能换。箱线放在散点之后,中央摘要才不会被点埋掉;散点放在小提琴之上,读者才能一边看形状一边数是多少个点撑出来的。
主图不放统计括号,把版面留给形状、四分位和原始点。
#| fig: layered
#| fig-width: 8
#| fig-height: 7
violin_plot <- ggplot(
iris_data,
aes(x = Species, y = Sepal.Length, fill = Species, color = Species)
) +
geom_violin(
trim = TRUE, # 轮廓停在实际最小值和最大值;尾部形状仍是密度估计
scale = "width", # 每组最宽处相同,宽度只用来比较形状,不编码样本量
width = 0.88,
alpha = 0.46,
linewidth = 0.9
) +
geom_jitter(
# 种子绑在 position 上,而不是在外面写一句 set.seed()。抖动是**每次绘制时**
# 算的,不是建对象时算的 —— 同一个对象画两次,点就落在不同位置。recipe 2
# 用的正是这个对象,不绑种子的话两张图的散点会对不上。
position = position_jitter(width = 0.08, seed = 123), # 只水平抖,垂直抖会改数值
shape = 21,
color = INK,
size = 1.8,
stroke = 0.35,
alpha = 0.72
) +
geom_boxplot(
width = 0.11, # 压窄,当作小提琴内部的一根"骨架"
outlier.shape = NA, # 离群点已经由上面的散点完整呈现,再画一次就是重影
fill = PAPER,
alpha = 0.92,
linewidth = 0.85
) +
scale_x_discrete(labels = species_labels) +
scale_y_continuous(
breaks = seq(4, 8, 0.5),
expand = expansion(mult = c(0.04, 0.08))
) +
scale_fill_manual(values = species_colors) +
scale_color_manual(values = species_colors) +
guides(fill = "none", color = "none") + # x 轴已经写了组名,图例是重复信息
labs(
title = "Sepal length by species",
subtitle = "Violin: density; box: quartiles; dots: observations",
x = NULL,
y = "Sepal length (cm)"
) +
theme_pubr(base_size = 13, legend = "none") +
theme(
plot.title = element_text(face = "bold", size = 17),
plot.subtitle = element_text(color = "#57564F", size = 10.5,
margin = margin(b = 10)),
axis.title.y = element_text(face = "bold", margin = margin(r = 10)),
plot.background = element_rect(fill = PAPER, colour = NA),
panel.background = element_rect(fill = PAPER, colour = NA)
)
violin_plot

