跳到正文

ggplot2分面与多图布局:facet_wrap、facet_grid 与 patchwork

数据里有第三个分类变量时,有两条路:映射成颜色或形状,或者拆成多个小面板。前者撑到三四组就到顶了,颜色再多读者分不清;后者撑十几组没问题。分面(facet)就是这套拆分机制。

facet_wrap() 接收一个离散变量,为它的每个水平画一个面板:

library(ggplot2)
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point() +
facet_wrap(vars(cyl))
图形输出:3 个面板,每个面板顶部有一条灰色标签条,分别写着 4、6、8。
所有面板共用同一套坐标刻度(x 轴 1.5–5.5,y 轴 10–35)。4 缸面板的点集中在左上角
(车轻、油耗低);6 缸面板居中;8 缸面板的点偏右下,wt 都在 3.17 以上、mpg 都低于 20。
默认布局是竖着一列。

vars() 把变量名包起来,避免写成字符串;facet_wrap(~ cyl) 这种老写法仍然有效。面板一多,默认布局不一定合意,用 nrowncol 指定:

ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point() +
facet_wrap(vars(cyl), nrow = 1)
图形输出:3 个面板横向排成一行。画布总宽度不变,所以每个面板变窄变扁,
点之间的相对位置不变,但 x 轴刻度标签自动减少到 2、3、4、5 以避免重叠。

nrow = 1 适合论文里横跨整栏的图;面板多(比如 12 个)时反过来用 ncol = 3 固定成三列,比自动布局好读。

默认标签只显示变量取值,读者得回头看正文才知道 4 代表什么。labeller 参数可以改:

ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point() +
facet_wrap(vars(cyl), nrow = 1, labeller = label_both)
图形输出:与上一张图相同,但标签条文字变成 "cyl: 4"、"cyl: 6"、"cyl: 8"。

label_both 是 ggplot2 自带的 labeller,同时显示变量名和取值;默认的 label_value 只显示取值。要换成中文,用 as_labeller() 传一个命名向量:

ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point() +
facet_wrap(
vars(cyl),
labeller = as_labeller(c("4" = "四缸", "6" = "六缸", "8" = "八缸"))
)

命名向量的名字必须和因子的水平完全一致(这里是字符串 "4",不是数字 4)。写错了不会报错,只是标签全部变空——排查标签问题时先看这里。

facet_grid() 接收一个公式:行变量 ~ 列变量。两个方向都要有东西,只想分一个方向时用 . 占位:

ggplot(ToothGrowth, aes(x = factor(dose), y = len)) +
geom_boxplot() +
facet_grid(supp ~ .)
图形输出:2 行 1 列共 2 个面板,从上到下标签为 OJ、VC,标签条贴在右侧。
两个面板的 x 轴(0.5 / 1 / 2)和 y 轴(0–35)刻度一致。
OJ 面板的中位数整体高于 VC 面板,两个面板内部都随剂量上升。

换成两个分组变量:

ggplot(ToothGrowth, aes(x = factor(dose), y = len)) +
geom_boxplot() +
facet_grid(supp ~ dose)
图形输出:2 行 3 列的网格,行标签 OJ、VC 在右侧,列标签 0.5、1、2 在顶部。
每个格子只包含一个剂量、一种给药方式的 10 个观测,所以箱体比前面几张图小得多。
剂量 2.0 那一列的两个箱体几乎一样高,此时两种给药方式看不出差别。

facet_wrap() 的区别在于行列是显式指定的:facet_grid() 给你一个完整的网格,即使某个组合在数据里不存在,位置也会留着(drop = FALSE 可以强制保留数据中未出现的因子水平,方便一组图之间布局对齐)。facet_grid() 还多一个 margins = TRUE 参数,会额外加一行一列汇总面板,标签写作 (all),用来对照各子组与总体的差异。

space = "free" 也只有 facet_grid() 有,常配合 scales = "free_x" 使用:面板的宽度与它实际覆盖的标度范围成正比,避免某个只有两个类别的面板和另一个有十个类别的面板一样宽。

facet_wrap() 其实也支持两个变量:facet_wrap(vars(supp, dose)) 会把 6 个组合摊成 6 个面板,标签条上写着 OJ, 0.5 这样的组合文字。区别在排布逻辑——grid 保留了行和列的语义,同一行的面板共享 supp,同一列共享 dose,读者可以顺着行或列扫视比较;wrap 只是把组合按顺序排开,换行位置由 nrow/ncol 决定,不承载任何含义。需要读者按行按列比较时用 facet_grid(),只是想让每个子集各占一格时用 facet_wrap()

面板的顺序按因子水平来。cyl 是数值时会按 4、6、8 排,字符型变量按字母排——想让面板按你指定的顺序出现(比如对照组排第一),先把它转成指定了 levels 的因子再画图,比事后手动调面板位置省事。

默认 scales = "fixed",所有面板共享坐标范围。好处是面板之间可以直接比高低:A 面板里的点比 B 面板高,就是真的高。

scales = "free"(或 "free_x""free_y")让每个面板各自适配自己的数据范围:

ggplot(airquality, aes(x = Day, y = Ozone)) +
geom_line() +
facet_wrap(vars(Month), ncol = 1, scales = "free_y")
图形输出:5 个纵向堆叠的面板,从上到下是 5 月到 9 月。x 轴仍共享(1–31),
但每个面板的 y 轴刻度不同:5 月的刻度上限明显低于 7、8 月,7、8 月要装下接近 170 的峰值。
面板高度一样,可同样的高度在 5 月代表 20 左右的浓度差,在 8 月代表 40 以上。

用 free 的前提是:这些面板本来就不能直接比高低。典型场景是每个面板对应一个独立的个体或地区,量级天生差一个数量级,共享刻度会把小量级的面板压成一条平线。风险也很直接:读者看图时习惯性地比较面板之间的高度和位置,刻度不一致就会得出错误结论,尤其柱状图——free_y 能把 12 和 14 的差别放大成两倍高的柱子。

所以用 free 的时候,y 轴的数字必须留着。见过有人加一句 theme(axis.text.y = element_blank()) 让图更“干净”,那等于把误读的大门敞开。另外,scales 只管 x 和 y 两个位置标度,颜色、大小的标度仍然全局统一;想给不同面板配不同的颜色标度,分面做不到,得拼图。

分面出来的面板共享一切:同一份数据、同一个坐标系定义、同一个颜色标度。要拼两张 y 变量不同、颜色标度也不同的图,用 patchwork:

library(patchwork)
p1 <- ggplot(mtcars, aes(x = wt, y = mpg)) + geom_point()
p2 <- ggplot(mtcars, aes(x = factor(cyl), y = mpg)) + geom_boxplot()
p3 <- ggplot(mtcars, aes(x = mpg)) + geom_histogram(bins = 10)
p1 + p2
p1 / p3
p1 + p2 输出:1 行 2 列的组合图,左边是 wt-mpg 散点图,右边是三个气缸组的箱线图,
各自保留自己的坐标轴和轴标题。
p1 / p3 输出:2 行 1 列的组合图,上面是散点图,下面是 mpg 的直方图(10 个组距),
两张图左右边缘对齐。

运算符一共三个:+| 横向排列,/ 纵向堆叠,可以加括号嵌套出「上面一张、下面两张」的布局,也可以用 plot_layout(ncol = 2) 覆盖默认排布。投稿时给子图打 A/B/C 标签:

(p1 + p2) +
plot_layout(ncol = 1) +
plot_annotation(tag_levels = "A")
图形输出:两张图上下排列,左上角分别出现 A 和 B 两个标签,标签字号跟随主题。

这里有个容易踩的坑:+ 在 patchwork 里的含义是「加到最后一个子图上」,不是「加到整张组合图上」。所以

p1 + p2 + theme_bw() # 只有 p2 变成 bw 主题
p1 + p2 & theme_bw() # 两张都变成 bw 主题

& 才是把元素应用到所有子图的运算符,写主题、写 labs(x = NULL) 这类统一调整时用它。图例如果来自同一个变量,可以用 plot_layout(guides = "collect") 合并成一个,省掉重复的色块。

拼好的组合图照样用 ggsave() 保存:

ggsave("combined.pdf", (p1 / p3), width = 8, height = 7)

补一句边界情况:patchwork 只能拼 ggplot 对象和能被包装成图形对象的图。base R 用 plot() 画的图要先经 wrap_elements() 包一层才能拼进来。

判断标准是「几张图之间要不要比较」。同一份数据、同一个变量的不同子集,用分面——共享坐标轴,读者比较起来省力,图注也能少写一半。y 变量不同、数据来源不同、颜色标度不同,才用 patchwork。

下一篇讲主题与出版级输出,包括中文字体和矢量图导出,见 /r/visualization/themes/。Python 侧的多子图布局(subplotsGridSpec、共享坐标轴)思路相近但接口更手工,可以对照 /python/visualization/subplots/