ggplot出现之前,R语言已经有多个绘图包,例如随R版本同步更新的 graphics包(也被称为 base绘图系统),grid包,基于 grid开发的 lattice包。然而,这些包总有抽象层次低、扩展性低等问题,这意味着需要直接和点线打交道,也给创建新的可视化方式带来困难。

ggplot是Hadley Wickham大神在博士期间编写的R包,完美解决了以上问题,实现了数据为导向的绘图。它允许多个图层叠加、将数据与样式分离,使用户可以根据数据特点组合绘图元素、设定绘图风格,自由地创作出各种图形。

ggplot的底层是 grid,使得直接操纵每一个点、每一条线成为可能,因而具有了极高的扩展性。目前,R社区已经有丰富的ggplot扩展包,这是其他绘图系统所不具备的。

本文面向使用过 ggplot的读者,目的是梳理 ggplot的设计理念、工作流程,以期面对众多绘图函数时举一反三、融会贯通。大多数观点来源于Wickham的书ggplot2: Elegant Graphics for Data Analysis (3e)官方文档

代码背后

创建画布

ggplot(...) +
 ...

调用 ggplot()会创建一个空白画布,此时可指定 datamapping=aes(...),这将作为全局设置被所有图层继承。

aes()中除了指定 x,y坐标以外,我们往往会指定美学映射,例如 color, size, shape等。这将使ggplot根据 data中映射的变量,决定绘图元素的颜色、尺寸、形状等。

新建图层

...
    geom_point(...) +
    ...

ggplot的一大特点是绘图区域(即坐标轴以内的区域)由图层堆叠而来,与Photoshop中的图层非常类似。每个图层都包含五大要素:数据(data)、美学映射(aesthetic mapping)、统计变换(statistical transformation)、几何对象(geometric object)、位置调整(position adjustment)。

新建图层使用 geom_xxx(),如果指定 datamapping=aes(),将仅在当前图层生效。需要放弃继承的全局映射时,指定 aes(xxx = NULL)aes()内的美学映射也可以直接写在 geom_xxx()中,指定一个常量。

统计变换由 stat指定,各种函数的默认值往往够用,例如 geom_bar()默认 stat='count'geom_histogram()默认 stat='bin'。如果使用 geom_xxx()系列函数,几何对象 geom也就被定下了。

指定 position可实现位置调整,例如 position_nudge(), position_jitter(), position_jitterdodge()常用于点图如 geom_point(),实现推拉、随机抖动等效果。position_stack(), position_fill(), position_dodge()常用于条形图如 geom_bar(),实现堆叠、填满、错位等效果。

图层会按照代码书写的顺序逐层绘制,因此有时会出现代码中靠后的图层遮挡了代码中靠前的图层,可根据需要调整代码顺序或设置不透明度 alpha

除了映射数据集中原有的列以外,有些统计变换会为数据集增加新的列,可在 aes()中指定。例如 geom_histogram(), geom_freqpoly()都会创建 density列,可通过 aes(y = after_stat(density))调用。

利用 aes()自动创建图例的特点,可间接实现为图层“命名”。例如第一个图层指定 aes(color='layer1'),第二个指定 aes(color='layer2'),两个图层的描边色将不同,并在图例中标注,这就将两个图层的元素区分开了。

geom_xxx()函数也有与图例相关的设置,例如 show.legend指定是否显示图例,key_glyph指定图例项目中标记的样子。

除了 geom_xxx()外,stat_xxx()系列函数也能创建图层,二者底层是等价的。它们都先根据 stat参数统计变换,再根据 geom参数调用 grid系统创建 Grob对象——也就是点、线等几何元素,最后用 layer()函数打包为 Layer对象。Wickham坦言区分二者是早期的设计失误。

下方的两种代码都可实现为分组散点图添加均值标线,在类似情况下,Wickham的建议是使用 geom_xxx()

mpg %>% ggplot(aes(x=class, y=cty)) + geom_jitter() +
    stat_summary(geom='errorbar', fun.min='mean', color='red')

mpg %>% ggplot(aes(x=class, y=cty)) + geom_jitter() +
    geom_errorbar(stat='summary', fun.min='mean', color='red')

有时需要在绘图区域添加注释,使用的函数分两类:第一类 geom_text(), geom_label(), geom_rect(), geom_vline()等继承以前图层的 data, mapping,第二类 annotate()则临时指定 data, mapping。其实两者底层是一致的,即 geom各异、stat='identity'Layer对象。

调整位置刻度

    ...
    scale_x_continuous(...) +
    ...

在ggplot中,每个 mapping=aes(...)内的变量都对应一种映射关系,或者说一种刻度(scale)。刻度分为位置刻度和美学刻度,它们除了影响绘图区域各种几何元素以外,在绘图区域外也存在某种指示(guides),展示数值空间与美学空间的对应关系。位置刻度对应的是坐标轴(axes),而其他美学刻度对应的是图例(legends)。

此处我们讨论 x,y,xend等位置刻度(position scales),其内部又分为连续型、离散型、时间型三种。

连续型位置刻度通过 scale_x_continuous()调整,name参数指定坐标轴名称,guide=guide_axis(...)调整坐标轴细节(如刻度标签)。

参数 trans指定如何转换坐标轴,也就是尺度变换(scale transformation),这会在 geom/stat图层的统计变换之前完成。

离散型位置刻度通过 scale_x_discrete()调整,离散变量会转换为从1开始的自然数序列,这意味着 x=1.5可以定位到前两个类别的中心。

离散型位置刻度的一个变体是分箱型位置刻度,通过 scale_x_binned()调整,n.breaks指定分箱个数

时间型位置刻度通过 scale_x_date()/scale_x_datetime()调整,label参数可指定格式化字符串,并支持在内部使用 \n换行。

对于所有位置刻度,参数 name指定坐标轴标签,可通过 name=quote(x^2)输入简单的数学公式。当不想要标签时,指定 name=''隐藏标签,但仍占用空间,name=NULL则取消空间占用。

参数 breakslabels指定坐标轴刻度、标签,除了手动指定也支持函数,scales包提供了许多助力。

调整美学刻度

    ...
    scale_color_manual(...) +
    ...

此处我们讨论 color, fill, shape, size, alpha, linetype, linewidth等生成图例的美学刻度,它们分别对应描边颜色、填充颜色、形状、尺寸、不透明度、线型、线宽。

大多数美学刻度都可通过形如 scale_xxx_manual(), scale_xxx_continuous(), scale_xxx_descrete(), scale_xxx_binned()的基本函数调整。如果使用时间数据映射,通过 scale_xxx_date(), scale_xxx_datetime()调整。如果 data中专门有一列存储色值,scale_color_identity()会特别有用。

另外,还有许多增强型函数,在 color, fill刻度上尤其多。例如 scale_color_brewer(), scale_color_viridis_c(), ggsci::scale_color_nejm(),都预设了某种颜色组合。scale_color_gradient(), scale_color_gradient2(), scale_color_gradientn()允许用户输入少量色值,由ggplot批量生成渐变色。scale_color_stepsn(), scale_color_fermenter()生成分箱型的色值,且内置了一系列颜色组合预设。

刻度 shape特指点的形状,不同 shape有不同的“描边-填充”组合。例如 geom_point()默认 shape=19,也就是全描边、无填充的圆点,此时点的颜色完全由 color决定,fill不起作用;如果指定 shape=21,你将得到具有描边、填充的圆点,此时点内部的颜色由 fill决定,而点圆周的颜色由 color决定。

|small

在上述 scale_xxx_xxx()函数中,na.value用于指定数值缺失时的默认映射,na.value=NA代表使缺失值不可见。

limits指定了要显示数据的范围。离散刻度需要列举可能值的多元向量,连续刻度需要指定上下界限的二元向量,超出范围的值默认视为 NA。对于连续刻度,可指定 oob=scales::oob_squish(),将超出范围的值视为 limits指定的上下界。

不同的刻度会产生不同的图例,调节方式也各不相同。对于连续型的 color, fill刻度,可使用 guide=guide_colorbar(...)调整图例。分箱型的 color, fill刻度,可使用 guide=guide_colorsteps()调整。分箱型的其他刻度,可使用 guide=guide_bins()调整。离散型的 color, fill及剩余所有刻度,则可使用 guide=guide_legend(...)

如果需要图例的显示与绘图元素不同,例如绘图指定了较低的不透明度,希望图例的不透明度较高,可指定 guide_legend(override.aes=list(alpha=1))。调整图例中各项目的布局,可指定 guide_legend(nrow=2, byrow=TRUE)

size, alpha刻度具有 range参数,可以指定刻度变化的范围。注意这和 limits的区别:limits从数据空间出发,指定要显示的数据范围是哪些,而 range从美学空间出发,指定同样的数据下,映射到点大小、不透明度等的变化范围。

size刻度默认将数值的比例关系与点的面积(而不是半径)建立联系,这在大多数时候符合直觉。但如果需要数值与半径对应(例如展示不同零件的半径差别),可使用 size_radius()

linetype刻度提供了 palette参数,允许用户自由定义所需的线型,控制线条、间隔的比例。

常用的快捷函数,例如 labs(), lims(), guides(),本质上是收集了不同scale的 name, limits, guide参数。

当然,还有少量美学刻度不会生成图例,最常见的就是 group。当你想在分组散点图的基础上,将组间具有对应关系的点用 geom_line()连起来时,指定 aes(group=...)非常关键。

stroke也是一个特殊的刻度,能指定描边宽度,但暂时没有对应的 scale_xxx_xxx()函数。如果想要调整、甚至添加图例,可使用:

+ continuous_scale(aesthetics="stroke", scale_name="stroke", 
    palette = function(x){scales::rescale(x, c(0, 6))})

调整坐标系统

    ...
    coord_cartesian(...) +
    ...

最常用的是笛卡尔坐标系 coord_cartesian(), coord_flip(), coord_fixed()是其翻转xy、固定xy轴单位刻度比的变体。非线性坐标系包括 coord_map()地图投影,coord_polar()极坐标,coord_trans()坐标变换。

参数 xlim, ylim可设定视窗范围,与 scale_x_continuous(limits=c(xmin, xmax))的区别在于,它执行的是移动视窗、缩放,不会对范围以外的数据做任何处理。

coord_trans()执行坐标变换(coordinate transformation),和 scale_x_continuous(trans=...)尺度变换(scale transformation)的区别在于,它在图层的统计变换(statistical transformation)之后完成。这意味着可以先做尺度变换,在变换后的数据空间做分析,然后使用坐标变换,在原始的数据空间可视化。

p <- ggplot(diamonds, aes(carat, price)) + geom_point() + geom_smooth(method = "lm") + 
  theme(legend.position = "none", axis.title=element_blank())

pow10 <- scales::exp_trans(10)
p + scale_x_log10() + scale_y_log10() + coord_trans(x=pow10, y=pow10)

分面

    ...
    facet_wrap(...) +
    ...

将数据拆分为子集,绘制在子图(称为“构面”)上。常用的是 facet_wrap(), facet_grid()

参数 scales指定不同子图 x,y轴是否保持一致范围,默认 fixed,即保持一致。当设为 free时,facet_wrap()会给每个子图添加独立的 x,y轴,而 facet_grid()仍然会保持每行公用一个 y轴,每列公用一个 x轴。

对于 facet_grid(),参数 space指定不同子图在 x,y方向是否占用相同空间,默认 fixed,即占用相同空间。当设为 free时,子图占据的空间不一致,但不同子图的单位刻度相同。

调整主题

mytheme <- theme_xxx(...) + theme(...)
# 全局指定
theme_set(mytheme)
# 临时指定
p + mytheme

经过以上步骤,与 data有关的部分,即所有几何元素都已被确定,剩下与 data无关的调整(所谓“风格”),由主题完成。

绝大多数情况下,主题设置不影响绘图区域,少数例外是 panel.grid, plot.background等。但在这些例外中,主题发挥的作用也与 data无关。

ggplot内置了一些基本主题,绘图的默认主题是 theme_grey(),最常用的可能是 theme_classic()。可用 base_size指定基础字号,这是 axis.title坐标轴标题的默认字号,其他文本在此基础上按比例增大、减小,如 plot.title是基础字号的1.2倍。

theme()函数可以在已指定的主题上修改,与前者衔接时,使用 +仅替换指定的属性,%+replace%会替换该元素下所有属性。例如指定 theme(axis.text.x=element_text(angle=45, hjust=1, vjust=1))axis.text.x元素下除了 angle, hjust, vjust外,还有 margin等属性,如果使用 + theme(...)margin仍然与 theme_grey()一致;使用 %+replace% theme(...)margin会成为 element_text()的默认值 NULL

aspect.ratio指定绘图区域的纵横比,注意和 coord_fixed()的区别,后者是指定xy轴单位刻度的比值。绘图区域1:1时,xy轴单位刻度比值不一定是1:1,反之亦然。

大多数主题设置都以 theme(元素=元素设置)的形式完成,命名遵循严谨的层级规则,完整清单见该链接。在元素命名中值得注意的是,plot代表整张图片,panel代表一个构面,strip代表构面的的注释区域,legendkey代表图例项的小图标。

元素设置往往借助元素函数,有四种基本类型 element_text(), element_line(), element_rect(), element_blank(),少数通过形如 unit(1, 'pt')grid单位,还有一些接受数值或字符串。借助 ggtext::element_markdown(),可将各类文本用markdown渲染。

你可能注意到,axis.title, axis.text等元素可以调整坐标轴,而在 scale_xxx_xxx()中调整 guide也可以实现类似效果。当两者冲突时,后者具有更高的优先级。

有关图例位置的几个元素需要特别说明:

  1. legend.position指定图例位置,除了使用 none, top, right等内置字符串外,也可以指定相对坐标,这时图例会放置在绘图区域内,例如 c(0, 1)放在左上角。在设置图例位置时,图例本身的锚点使用 legend.justification指定,所以如果图例很大,要想美观地放置在绘图区域左上角,legend.position, legend.justification两个参数都需设置为 c(0, 1)
  2. legend.direction指定图例内部各项目的排列方向,可选行或者列。
  3. legend.spacing指定图例各项目中,小图标与文本的间距。
  4. 当多种刻度(例如 color, shape)都有图例时,legend.box指定不同图例的排列方式,legend.box.just指定对齐方式。

保存输出

ggplot绘制的是单张图片,可使用 patchwork包将多张图片组合。

另存到文件时,一般使用 ggsave()函数。指定文件的后缀,可以自动选择图形设备,其中 png, jpg是常用的位图/栅格图设备,pdf是常用的矢量图设备。

参数 width, height画布尺寸,默认单位是 inch英寸,调整画布尺寸会影响画面元素的紧密程度。

参数 dpi分辨率,默认300,调整分辨率会影响与 Adobe Illustrator (AI)中显示的点数(也称为磅,pt)的对应关系。主题 element_text(size=5)的默认单位就是 pt,因此 dpi维持默认300时,主题中设置的字号与 AI显示的一致。但图层 geom_xxx(size=5)的默认单位是 mmdpi维持300时,图层中设置的字号要乘 2.845换算为 pt,才是 AI里显示的字号。

底层顺序

以上,我们梳理了在代码书写的视角下,ggplot都做了些什么。在 ggplot底层,真实发生的顺序是:

  1. 创建画布,读取各个图层的数据集,将 data内的变量分配到刻度,识别哪些是位置刻度、哪些是美学刻度。ggplot内部将会维护一张列名是各种刻度的表格,并在后续操作中不断更新。
  2. 分面(facet),体现在内部表格的 PANEL列。
  3. 位置刻度的尺度变换(scale transformation),例如 scale_x_log10()/scale_x_continuous(trans='log10')指定的对数变换。
  4. 图层的统计变换(statistical transformation),即 geom_xxx()/stat_xxx()内含的计数、取bin、核密度、回归、分位数等,也包括 ggsignif::geom_signif()内含的p值计算。变换时会考虑分面、分组情况。
  5. 图层的位置调整(position),即 geom_xxx()/stat_xxx()中的 position参数。
  6. 刻度的训练(scale trainning),综合考虑所有构面、所有图层,以获得数据空间到美学空间的一致映射关系。如果存在 coord_trans()定义的坐标轴变换,在此时完成。
  7. 刻度映射(scale mapping)。对位置刻度来说就是建立坐标系,对美学刻度来说是确定颜色、建立图例等。
  8. 渲染(render),往往在打印或保存到文件时才执行。ggplot先将数据转换为 grobs对象,部分 geom_xxx()函数此时会进行额外的统计变换。然后把每个构面都整理为一个 gList对象,包括绘图区域和应用了主题的绘图区域外部分,但不包括图例、标题、边距、背景等。最后,ggplot合并整张图,处理图例和剩余部分,得到最终的 gtable对象,并调用 grid包执行绘图。

希望读到此处的你,能和 ggplot玩得开心!