1. 散点图搭配边际分布图,到底解决了什么痛点
做过数据分析的人都有个体会:单纯一张散点图,能看两个变量之间有没有关系、关系大致是线性还是非线性、有没有明显的离群点,但它有个天然的短板——看不出单个变量自己的分布形态。比如你手头有一份几百上千行的数据集,想同时回答三个问题:X和Y有没有相关性?X自己是不是偏态分布?Y有没有双峰?如果只画散点图,你只能回答第一个;如果分开画直方图,又割裂了两个变量之间的联动关系。
边际分布图(Marginal Distribution Plot)就是冲着这个痛点来的。它的核心思路很朴素:在主图(通常是散点图)的上方和右侧,各挂一个单变量分布图,上方对应X轴的分布,右侧对应Y轴的分布,主图和边际图共享坐标轴。这样一眼扫过去,既能看相关趋势,又能看各自的分布形状,还能顺带发现"某个区间样本特别密集"这类信息。
在R语言里,实现这套组合图的方案不止一种,最主流的是ggplot2配合ggExtra包里的ggMarginal()函数,另外cowplot、patchwork、gridExtra这些拼图包也能手动拼出来,还有GGally包的ggpairs()可以做多变量矩阵版的边际图。这篇文章我会围绕最常用、最容易复现的方案展开,把选型逻辑、参数细节、踩坑经验一次讲透,适合刚接触R可视化、想快速出图的分析人员,也适合已经会用ggplot2但没系统研究过边际图的进阶用户。
我自己的使用场景主要集中在探索性数据分析(EDA)阶段,尤其是拿到一份新数据、还没确定建模方向的时候,一张带边际分布的散点图往往比跑一堆统计检验更直观。下面按"设计思路—核心细节—实操流程—问题排查"的顺序展开,中间会穿插大量参数解释和实测经验。
2. 方案选型与整体设计思路拆解
2.1 为什么首选 ggExtra 而不是手动拼图
R里画边际图的思路可以分成两大类:一类是"一体化"方案,比如ggExtra::ggMarginal(),你先把主图(ggplot对象)画好,再把这个对象丢给ggMarginal(),它自动帮你把边际图挂上去;另一类是"拼图"方案,用cowplot或patchwork把三个独立的图(主散点图、上方密度图、右侧密度图)按布局拼起来。
我实测下来,90%的场景首选ggMarginal(),原因有三点。第一,它和ggplot2无缝衔接,你不需要重新计算边际图的坐标范围,它会自动对齐主图的xlim和ylim,手动拼图时这一步最容易出错——边际图的坐标轴和主图对不齐,图就废了。第二,它支持density、histogram、boxplot、violin四种边际图类型,切换只需要改一个参数。第三,代码量极少,通常一行就能加上。
那什么时候用拼图方案?两种情况:一是你需要对边际图做非常精细的定制(比如在边际图上再加注释、改特定区域颜色),ggMarginal()的定制能力有限;二是你要拼的不是标准散点图,而是其他类型的主图,ggMarginal()对主图类型有一定要求。这两种情况我会在2.3节展开。
2.2 边际图类型的选择逻辑
ggMarginal()的type参数决定了边际图画什么,四个选项各有适用场景,选错了会误导读者。我整理了一张对照表:
| 边际图类型 | 参数值 | 适用场景 | 不适用场景 |
|---|---|---|---|
| 密度曲线 | density | 样本量较大(>200),想看分布形状、偏态、多峰 | 样本量很小,曲线抖动严重 |
| 直方图 | histogram | 想看具体分箱频数,样本量中等 | 分箱数难定,容易误导 |
| 箱线图 | boxplot | 想快速看中位数、四分位、离群点 | 想看完整分布形状 |
| 小提琴图 | violin | 兼顾分布形状和分位数信息 | 样本量小,形状不可靠 |
我个人的默认选择是density,因为密度曲线最平滑、信息密度最高,尤其适合连续变量。但如果你的数据里有明显的分组(比如按类别分色),密度曲线会重叠在一起看不清,这时候histogram配合fill参数反而更清楚。箱线图我一般只在报告里用,因为业务方对箱线图的接受度更高,一眼能看出中位数和异常值。
提示:样本量低于100时,密度曲线会出现明显的"毛刺",这时候建议改用
histogram,或者干脆不加边际图,直接看散点图。
2.3 手动拼图方案的适用边界
前面说了ggMarginal()覆盖大部分场景,但有两种情况必须手动拼。第一种是主图不是标准散点图,比如你想画的是六边形分箱图(geom_hex)或者带误差棒的散点,ggMarginal()对这类主图的支持不稳定,实测有时会报错或对齐错位。第二种是边际图需要深度定制,比如你想在右侧边际图上叠加一条参考线、标注特定分位数,ggMarginal()的margins参数只能控制画哪几个边,没法往里加图层。
手动拼图的思路是:先用ggplot2分别画三个图,主图用theme(axis.title.x = element_blank())之类的把重复的轴标签去掉,然后用cowplot::plot_grid()按"上、左、右"的布局拼。这里有个关键技巧——主图和边际图的坐标范围必须手动统一。比如主图x轴范围是0到100,那上方密度图的x轴也必须是0到100,否则拼出来宽度对不上。我一般用coord_cartesian(xlim = c(0, 100))在主图和边际图上各设一次,保证一致。
2.4 数据准备阶段的三个前置判断
在动手画图之前,有三件事必须先确认,否则后面会反复返工。第一,变量类型。边际图本质是单变量分布,只对连续变量有意义。如果你的X是分类变量,边际图应该用条形图而不是密度图,这时候ggMarginal()就不合适了,得手动拼。第二,缺失值处理。散点图默认会丢掉含NA的行,但边际图如果单独计算,可能用的是完整数据,导致主图和边际图的样本量不一致,分布对不上。我的做法是画图前先用tidyr::drop_na()把相关列的空值清掉,保证三个图用的是同一份数据。第三,离群点。如果数据里有极端离群点,主图的坐标轴会被拉得很长,边际图的分布会被压缩到一小块,看不清形状。这时候要么用coord_cartesian()截断坐标轴(注意不是xlim(),后者会真的删数据),要么先处理离群点。
3. 核心细节解析与实操要点
3.1 ggMarginal 的关键参数逐个拆解
ggMarginal()的函数签名不长,但每个参数都有讲究。我按使用频率从高到低拆一遍。
p参数是主图对象,必须是一个ggplot对象,不能是ggplot()还没加图层的空对象。type前面说过了,控制边际图类型。margins参数决定画哪几个边,可选"both"(默认,上下左右都画)、"x"(只画上方)、"y"(只画右侧)。我一般用"both",但如果主图很宽,上方边际图会被拉得很扁,这时候可以只画右侧。
size参数控制边际图的相对大小,默认是5,数值越大边际图占的比例越大。实测下来,size = 5到size = 8比较合适,超过10主图就被挤得太小了。groupColour和groupFill是两个容易混淆的参数:groupColour按分组给边际图的线条上色,groupFill按分组给填充区域上色。如果你的主图用了aes(color = group),想让边际图也跟着分组,这两个参数都要设成TRUE。
bins参数只在type = "histogram"时生效,控制分箱数,默认30。分箱数太少会掩盖分布细节,太多会显得毛糙,我一般根据样本量调,样本量1000左右用30到40比较合适。binwidth是bins的替代方案,直接指定箱宽,两者选一个就行。
trans参数控制边际图的坐标变换,比如trans = "log"可以把边际图的x轴转成对数轴。这个参数在主图用了对数坐标时特别有用,能保证主图和边际图的变换一致。
3.2 分组散点图的边际图处理
实际分析里,散点图经常按类别分色,比如按性别、按地区。这时候边际图怎么处理分组,是个容易踩坑的地方。默认情况下,ggMarginal()画的是所有数据混在一起的边际分布,不区分组。如果你想让边际图也按组分开画,需要设groupColour = TRUE和groupFill = TRUE。
但这里有个陷阱:密度曲线分组后如果重叠严重,会互相遮挡。我遇到过三组数据密度曲线几乎完全重合的情况,画出来就是一团糊。解决办法有两个:一是改用type = "histogram"配合position = "identity"和透明度,让重叠部分可见;二是干脆不分组,用整体分布,然后在正文里说明分组情况。
还有一个细节:groupFill = TRUE时,填充区域默认是半透明的,但不同版本的ggExtra透明度默认值不一样,有的版本填充太实,会盖住下面的曲线。我一般手动在ggMarginal()外面套一层,或者干脆用type = "density"只画线条不填充,视觉上更干净。
3.3 主题与配色的一致性维护
主图和边际图如果主题不一致,拼出来会很违和。比如主图用了theme_minimal(),边际图却带着灰色背景和网格线,一眼就能看出是两张图拼的。ggMarginal()会继承主图的部分主题设置,但不是全部,尤其是axis.text、axis.title这些和坐标轴相关的元素,边际图有自己的默认值。
我的做法是:先定义一套统一的主题,比如my_theme <- theme_minimal() + theme(panel.grid.minor = element_blank()),然后主图用这套主题,边际图通过ggMarginal()的...参数传入额外的主题设置。实测下来,边际图最需要调的是axis.text的大小,因为边际图通常比主图小,默认字号会显得太大。我一般把边际图的axis.text设成主图的0.7倍左右。
配色方面,如果主图用了自定义色板,边际图的分组颜色最好也用同一套。ggMarginal()默认会继承主图的颜色映射,但如果你手动指定了groupColour,它会用ggplot的默认色板,可能和主图不一致。稳妥的做法是在主图里用scale_color_manual()定义好色板,边际图会自动继承。
3.4 输出尺寸与分辨率的参数计算
边际图因为多了上方和右侧两个子图,整体宽高比和普通散点图不一样。如果按普通散点图的尺寸导出,边际图会被压得很扁。我一般按主图宽高比接近1:1来设,比如主图是6英寸宽、5英寸高,加上边际图后整体设成7英寸宽、6英寸高。
导出时用ggsave(),dpi参数建议至少300,用于印刷的话设600。这里有个细节:ggMarginal()返回的对象类型是ggExtraPlot,ggsave()能直接识别,但如果你用的是RStudio的Export按钮,有时候会导出成空白图,这是已知的兼容性问题,建议一律用ggsave()命令行导出。
注意:
ggsave()的width和height参数单位默认是英寸,如果你习惯用厘米,记得加units = "cm",否则图会小得离谱。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭好。需要的包不多,核心是ggplot2和ggExtra,辅助的用dplyr做数据处理,palmerpenguins提供示例数据(这个包自带一份干净的企鹅测量数据,非常适合演示,不用自己造数据)。
install.packages(c("ggplot2", "ggExtra", "dplyr", "palmerpenguins"))装完后加载:
library(ggplot2) library(ggExtra) library(dplyr) library(palmerpenguins)这里提醒一句:ggExtra依赖ggplot2的版本,如果你的ggplot2是很老的版本(比如3.0以下),ggMarginal()可能会报错。建议先把ggplot2升到最新版再装ggExtra。我踩过一次坑,ggplot2是2.3版本,ggMarginal()画出来的边际图坐标轴完全错位,升级后就好了。
4.2 数据清洗与前置检查
用palmerpenguins里的penguins数据集,这份数据有344行,包含企鹅的喙长、喙深、鳍长、体重等测量值,还有物种、岛屿、性别等分类变量。先做基本检查:
data(penguins) glimpse(penguins) sum(is.na(penguins))glimpse()会告诉你每列的类型和前几个值,sum(is.na())统计缺失值总数。这份数据有少量缺失值,画图前先清掉:
penguins_clean <- penguins %>% drop_na(bill_length_mm, bill_depth_mm, species)这里只保留了我们关心的三列:喙长、喙深、物种。drop_na()会把这三种任意一列有缺失的行都删掉,保证后面三个图用的是同一份数据。
4.3 主散点图的绘制与调优
先画主图。我想看喙长和喙深的关系,按物种分色:
p <- ggplot(penguins_clean, aes(x = bill_length_mm, y = bill_depth_mm, color = species)) + geom_point(alpha = 0.7, size = 2) + labs(x = "喙长 (mm)", y = "喙深 (mm)", color = "物种") + theme_minimal() + theme(legend.position = "bottom")alpha = 0.7让点半透明,重叠的点能看出密度;size = 2比默认的1.5稍大,投影时更清楚;legend.position = "bottom"把图例放底部,避免和右侧边际图抢空间。这一步画出来的就是普通散点图,先确认散点图本身没问题,再加边际图。
4.4 叠加边际分布图的完整代码
主图确认没问题后,一行代码加边际图:
p_marginal <- ggMarginal( p, type = "density", margins = "both", size = 6, groupColour = TRUE, groupFill = TRUE, alpha = 0.3 )alpha = 0.3是传给边际图的透明度参数,让填充区域半透明,重叠的曲线能看清。size = 6是我实测下来比较平衡的值,主图不会被挤得太小。groupColour和groupFill都设TRUE,让边际图按物种分色。
画出来后你会看到:上方是三个物种喙长的密度曲线,右侧是喙深的密度曲线,主图是三个物种的散点。一眼就能看出,某一种企鹅的喙明显更长、更浅,另外两种更接近。
4.5 导出高分辨率图片
最后导出:
ggsave( "penguin_scatter_marginal.png", plot = p_marginal, width = 8, height = 7, dpi = 300, units = "in" )width = 8、height = 7是我针对这张图调过的比例,主图接近正方形,边际图不会被压扁。dpi = 300保证印刷质量。导出后建议打开图片检查一遍,重点看边际图的坐标轴和主图是否对齐、图例有没有被裁掉。
4.6 手动拼图方案的完整实现
前面说了ggMarginal()覆盖大部分场景,但如果你需要深度定制,手动拼图是备选。这里给一个完整实现,用cowplot拼三个图:
library(cowplot) # 主图,去掉x轴标签(因为上方边际图会带) main_plot <- ggplot(penguins_clean, aes(x = bill_length_mm, y = bill_depth_mm, color = species)) + geom_point(alpha = 0.7, size = 2) + scale_color_manual(values = c("#E69F00", "#56B4E9", "#009E73")) + coord_cartesian(xlim = c(30, 60), ylim = c(13, 22)) + theme_minimal() + theme(legend.position = "none", axis.title.x = element_blank()) # 上方密度图 top_plot <- ggplot(penguins_clean, aes(x = bill_length_mm, fill = species)) + geom_density(alpha = 0.4) + scale_fill_manual(values = c("#E69F00", "#56B4E9", "#009E73")) + coord_cartesian(xlim = c(30, 60)) + theme_void() + theme(legend.position = "none") # 右侧密度图,注意coord_flip right_plot <- ggplot(penguins_clean, aes(x = bill_depth_mm, fill = species)) + geom_density(alpha = 0.4) + scale_fill_manual(values = c("#E69F00", "#56B4E9", "#009E73")) + coord_cartesian(xlim = c(13, 22)) + coord_flip() + theme_void() + theme(legend.position = "none") # 拼图 final_plot <- plot_grid( top_plot, NULL, NULL, NULL, main_plot, right_plot, ncol = 3, rel_widths = c(1, 4, 1.2), rel_heights = c(1, 4) )手动拼图的关键点:主图和边际图的坐标范围必须手动统一,这里用coord_cartesian()在主图和上方图都设了xlim = c(30, 60),右侧图设了xlim = c(13, 22)。右侧图还要加coord_flip()把密度图转成横向。rel_widths和rel_heights控制各块的比例,主图占大头,边际图占小头。
5. 常见问题与排查技巧实录
5.1 边际图与主图坐标轴错位
这是最高频的问题,表现为上方密度图的峰值位置和主图散点的密集区对不上。原因通常是主图用了xlim()或ylim()截断了数据,而边际图用的是完整数据。xlim()会真的删掉范围外的数据点,但边际图如果单独计算,用的还是原始数据,两边样本量不一致,分布自然对不上。
解决办法:统一用coord_cartesian()代替xlim()/ylim()。coord_cartesian()只改变可视范围,不删数据,主图和边际图用的还是同一份数据。如果你确实需要删数据,那就在画图前用filter()删,保证三个图用的是同一份清洗后的数据。
5.2 分组密度曲线重叠看不清
前面提过,三组数据密度曲线重合时会糊成一团。除了改用直方图,还有一个技巧:调整alpha值。ggMarginal()的alpha参数控制填充透明度,默认值在不同版本里不一样,有的版本默认0.5,有的默认1。我一般显式设成0.3到0.4,让重叠部分能透出来。
如果还是看不清,可以考虑只画线条不填充,即groupFill = FALSE、groupColour = TRUE,这样三组曲线就是三条不同颜色的线,重叠也能区分。代价是失去了填充带来的"面积感",但清晰度优先。
5.3 导出图片时边际图被裁切
用ggsave()导出时,如果width和height设得太小,边际图可能被裁掉一部分。这是因为ggMarginal()返回的对象在计算尺寸时,边际图是"附加"在主图外面的,如果画布不够大,附加部分就被切了。
解决办法:导出尺寸比主图单独导出时大一圈。比如主图单独导出用6x5,加边际图后至少用7x6。我一般按主图尺寸的1.2到1.3倍来设。另外,ggsave()的plot参数要显式指定为ggMarginal()的返回值,不要用默认的"最后一张图",因为ggMarginal()内部可能又画了别的图,默认值会抓错。
5.4 中文标签显示成方框
如果坐标轴标签或图例用了中文,导出时可能显示成方框,这是字体问题。解决办法是在theme()里指定支持中文的字体:
theme(text = element_text(family = "SimHei"))Windows系统用SimHei(黑体),Mac系统用PingFang SC或STHeiti。注意ggMarginal()的边际图可能不继承这个字体设置,需要在ggMarginal()里再传一次theme参数。实测下来,最稳妥的做法是在画主图时就设好字体,ggMarginal()会继承大部分主题设置。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 边际图与主图错位 | 用了xlim/ylim截断数据 | 改用coord_cartesian |
| 密度曲线糊成一团 | 分组重叠+透明度太低 | 调高alpha或改用直方图 |
| 导出图片被裁切 | 画布尺寸太小 | 尺寸设为主图的1.2-1.3倍 |
| 中文显示方框 | 字体不支持 | 指定SimHei或PingFang SC |
| 边际图不显示分组颜色 | 没设groupColour/groupFill | 两个参数都设TRUE |
| ggsave导出空白 | RStudio Export兼容问题 | 用ggsave命令行导出 |
5.6 几个我踩过的坑
第一个坑是**ggMarginal()对主图的图层类型有要求**。我试过在主图里加geom_smooth(),结果边际图报错。后来查文档才知道,ggMarginal()只识别主图的第一个几何对象,如果第一个是geom_smooth而不是geom_point,它算边际分布时就会出错。解决办法是把geom_point()放在最前面,geom_smooth()放后面。
第二个坑是样本量太大时密度曲线计算慢。我有一次用了几十万行的数据,ggMarginal()跑了快一分钟才出图。后来发现是密度估计的计算量大,解决办法是先用sample_n()抽样,或者改用type = "histogram",直方图的计算量小得多。
第三个坑是**size参数在不同版本里含义不同**。早期版本的size是边际图的绝对尺寸,新版本改成了相对比例。如果你照着老教程设size = 20,在新版本里边际图会占掉大半个画布。建议先看当前版本的文档,或者从size = 5开始试。
6. 进阶玩法与场景延展
6.1 多变量矩阵版的边际图
如果你有多个连续变量想看两两关系,GGally::ggpairs()可以一次性画出矩阵图,对角线是单变量分布,非对角线是散点图。这相当于把边际图的思路扩展到了多维。用法很简单:
library(GGally) ggpairs(penguins_clean, columns = c("bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"), aes(color = species, alpha = 0.5))columns指定要画的列,aes(color = species)按物种分色。ggpairs()的对角线默认是密度图,非对角线是散点图加相关系数。这个图信息量极大,适合在EDA阶段快速扫一遍所有变量的关系。缺点是变量多了图会很挤,建议控制在4到5个变量以内。
6.2 边际图叠加参考线
ggMarginal()本身不支持在边际图上加图层,但有个变通办法:用grid包的grid.lines()在画好的图上手动加线。这个操作比较hack,需要先grid.newpage()、grid.draw()画出ggMarginal()对象,再用grid.lines()在指定位置画线。坐标要自己算,比较麻烦,但确实能实现。
更简单的办法是改用拼图方案,在边际图的ggplot对象里直接加geom_vline()。比如你想在右侧密度图上标出中位数,就在右侧图的代码里加geom_vline(xintercept = median_value, linetype = "dashed")。这也是我推荐拼图方案的原因之一——定制自由度高。
6.3 边际图在时间序列探索中的应用
边际图不只能用在散点图上。如果你做时间序列分析,可以把"时间"作为X轴、"数值"作为Y轴画散点图,上方边际图看时间分布(能发现采样是否均匀),右侧边际图看数值分布(能发现异常值)。这个用法比较小众,但在传感器数据分析里很实用。我做过一个项目,上方边际图一眼就看出某段时间采样频率突然变高,后来查出来是设备在那段时间进入了高频采集模式。
6.4 性能优化:大数据集下的处理策略
数据量超过10万行时,ggMarginal()的密度估计会明显变慢。我的优化策略分三步:第一步,抽样。如果只是看分布形状,随机抽1万行足够了,sample_n(penguins_clean, 10000)。第二步,改用直方图。type = "histogram"比type = "density"快很多,因为不需要核密度估计。第三步,预先计算密度。如果一定要密度曲线,可以先用density()函数算好密度值,再用geom_line()画,避免ggMarginal()内部重复计算。
提示:抽样会改变分布形状,尤其是长尾分布,抽样后尾部可能消失。如果关心尾部,不要抽样,改用直方图。
6.5 与其他可视化方案的对比
最后简单对比一下边际图和其他"看分布"的方案。小提琴图能看分布,但只能看一个变量,没法看两个变量的关系。二维密度图(geom_density_2d)能看两个变量的联合分布,但看不出各自的边际分布。六边形分箱图(geom_hex)适合大数据集,但同样缺边际信息。边际图的独特价值就在于同时呈现联合关系和边际分布,这是其他方案替代不了的。
我在实际项目里的体会是,边际图最适合放在EDA报告的第一页,让读者一眼抓住数据的整体形态。等确定了建模方向后,再针对性地画更精细的图。它不是一个"终极方案",而是一个"快速建立直觉"的工具。用对了场景,一张图能省掉半小时的口头解释。