ggplot2分面与多图布局:facet_wrap、facet_grid 与 patchwork
数据里有第三个分类变量时,有两条路:映射成颜色或形状,或者拆成多个小面板。前者撑到三四组就到顶了,颜色再多读者分不清;后者撑十几组没问题。分面(facet)就是这套拆分机制。
facet_wrap:一个变量的网格
Section titled “facet_wrap:一个变量的网格”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) 这种老写法仍然有效。面板一多,默认布局不一定合意,用 nrow 或 ncol 指定:
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:行 × 列
Section titled “facet_grid:行 × 列”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 = “free” 的前提与风险
Section titled “scales = “free” 的前提与风险”默认 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 两个位置标度,颜色、大小的标度仍然全局统一;想给不同面板配不同的颜色标度,分面做不到,得拼图。
patchwork:拼独立的图
Section titled “patchwork:拼独立的图”分面出来的面板共享一切:同一份数据、同一个坐标系定义、同一个颜色标度。要拼两张 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 + p2p1 / p3p1 + 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() 包一层才能拼进来。
什么时候分面,什么时候拼图
Section titled “什么时候分面,什么时候拼图”判断标准是「几张图之间要不要比较」。同一份数据、同一个变量的不同子集,用分面——共享坐标轴,读者比较起来省力,图注也能少写一半。y 变量不同、数据来源不同、颜色标度不同,才用 patchwork。
下一篇讲主题与出版级输出,包括中文字体和矢量图导出,见 /r/visualization/themes/。Python 侧的多子图布局(subplots、GridSpec、共享坐标轴)思路相近但接口更手工,可以对照 /python/visualization/subplots/。