Folioevanzhou.org
← 返回 Tessera
R3 张图2026-04-13

violin

One mirrored density curve per group, wide where observations pile up and narrow where they thin out.

示例数据
iris
配色
walter_white2
语言
R
成图预览另有 2 张在配方里
violin

Introduction

小提琴图回答的是分布的形状:一组数值在哪一段密集、哪一段稀疏,是一个峰还是两个。箱线图给的是五个数(中位数、四分位、须),形状被压掉了——两组数据可以有完全相同的中位数和四分位距,一组是单峰、另一组是两个峰挤出来的,画成箱线图长得一模一样。

所以它的场合是:怀疑组间差的不只是位置,还有形状。 只关心"哪组更高",箱线图更省版面;每组只有十来个观测,密度估计撑不起形状,直接画散点。

读的时候按这个顺序:

  1. 先看位置。 哪组整体偏高、哪组偏低——这部分箱线图也能回答。
  2. 再看宽度。 越宽说明那一段数值附近的样本越密。宽度怎么缩放由 scale 决定,不是固定含义。
  3. 看是不是单峰。 出现两个鼓包,说明这一组内部可能还分着亚群;这是小提琴图唯一能给、箱线图给不了的信息。
  4. 回头数散点。 形状可不可信取决于有多少个点在撑着它。x 轴标签里的 n 和图上的点都是为这一步准备的。
  5. 最后才看星号。 它是补充,替代不了前面四步。

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
violin — layered
violin-layered

2 · ggpubr

要把统计结果直接加到图上,就在同一个 violin_plot 上叠 stat_compare_means():它内部算检验,括号位置由数据范围和 step.increase 自动安排,不需要另造结果表或手写绝对坐标。

整体用 Kruskal–Wallis,两两用 Wilcoxon 加 Holm 校正——两层都是秩检验,假设一致。把 ANOVA 和 Wilcoxon 括号凑在同一张图上,读者没法知道这张图到底建立在哪套假设上。

星号只负责快速扫读。它说的是"差异是否明确",不是"差异有多大";具体 p 值和效应量仍应进正文或结果表。

这里保留全部三对比较,是为了完整示范 API。四组就有六对,括号会把图占满;真实分析里只有预先指定的一两对比较,就直接缩短 pairwise,不要先算完所有组合再按显著性挑着画。

#| fig: stats
#| fig-width: 8
#| fig-height: 7
# 比较对写死在这里,是一个分析决定:哪几对值得比,看研究问题,不看算出来的 p
pairwise <- list(
  c("setosa", "versicolor"),
  c("versicolor", "virginica"),
  c("setosa", "virginica")
)

y_max <- max(iris_data$Sepal.Length)

violin_plot +
  stat_compare_means(
    comparisons   = pairwise,
    method        = "wilcox.test",
    p.adjust.method = "holm",
    label         = "p.signif",
    step.increase = 0.09,   # 三层括号之间的间距,按 y 轴范围的比例算
    tip.length    = 0.008,
    size          = 4.2,
    bracket.size  = 0.55
  ) +
  stat_compare_means(
    method = "kruskal.test",  # 整体检验,和上面的两两比较同属秩检验
    label.x = 1.05,
    label.y = y_max * 1.22,
    hjust = 0,
    size = 4
  ) +
  scale_y_continuous(
    breaks = seq(4, 10, 1),
    # 顶部留够三成空白:括号和整体检验那行字都画在数据上方,
    # 留白不够会被直接裁掉,而且裁掉之后图看上去仍然是完整的
    expand = expansion(mult = c(0.04, 0.3))
  ) +
  labs(subtitle = "Kruskal-Wallis overall; pairwise Wilcoxon with Holm correction") +
  theme(plot.margin = margin(12, 18, 16, 24))
violin — stats
violin-stats

3 · ggstatsplot

一个函数把小提琴、箱线、散点、整体检验、两两比较和效应量全都摆出来。

代价是版面:副标题里那串统计量、比较括号、均值标签和侧边说明同时在争注意力。它适合探索阶段,用来判断下一步该报告什么。

#| fig: ggstats
#| fig-width: 8
#| fig-height: 6
library(ggstatsplot)

ggbetweenstats(
  data = iris_data,
  x    = Species,
  y    = Sepal.Length,
  type = "parametric",          # 参数检验;"nonparametric" 走 Kruskal-Wallis
  pairwise.comparisons = TRUE,
  pairwise.display = "significant",
  p.adjust.method = "holm",
  var.equal = FALSE,            # Welch ANOVA;两两比较自动对应 Games–Howell,
                                # 整体和两两仍是同一套假设
  mean.ci   = TRUE,
  results.subtitle = TRUE,      # 副标题里那一串统计量
  centrality.point.args = list(size = 4, color = INK),
  centrality.label.args = list(size = 3, nudge_x = 0.38,
                               segment.linetype = 4),
  point.args = list(
    position = position_jitterdodge(dodge.width = 0.55, seed = 123),
    alpha = 0.55,
    size = 2.2,
    stroke = 0.25
  ),
  boxplot.args = list(width = 0.18, alpha = 0.12),
  violin.args = list(width = 0.68, alpha = 0.28),
  ggtheme = ggpubr::theme_pubr(base_size = 14),
  # 只能从外面塞一层 scale 进去:颜色由函数内部接管,不像 recipe 1 那样直接给
  ggplot.component = scale_color_manual(values = species_colors),
  title = "Iris: Sepal Length by Species",
  xlab = "", ylab = "Sepal Length"
)
violin — ggstats
violin-ggstats

Constraints

  • 小样本时形状不可信。 密度估计要足够多的点撑着,每组不到二三十个时,"看起来有两个峰"多半是噪声。所以图上一定叠散点、坐标轴一定写 n。
  • 轮廓不是数据边界。 两端来自核密度估计,会超出实际的最大最小值。trim = FALSE 让尾巴更完整,但那里并没有数据。
  • 宽度的含义由 scale 决定。 scale = "width" 让每组最宽处一样宽,只能比形状;scale = "count" 让样本少的组整体变窄,宽度同时编码样本量。不写这个参数就默认宽度只由密度决定,是读错图。

三个 recipe 怎么比

  1. 正式展示 → recipe 1。三种视觉层各回答一个问题,字体、留白和颜色能和同一份报告里的其它图对齐。
  2. 正式展示且结论就是组间差异 → recipe 1 加 recipe 2,只保留预先指定的那几对比较。
  3. 探索阶段 → recipe 3。一次拿到 Welch ANOVA、Games–Howell、效应量和置信区间,适合判断下一步报告什么,但不要原样当发表图。
运行环境4 个包 · 2026-09-02T13:29:19.191+0800

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • biopalette 0.2.2
  • ggplot2 4.0.3
  • ggpubr 0.6.2
  • ggstatsplot 0.13.0