ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

R语言散点图边际分布:ggExtra与ggpairs实战指南

R语言散点图边际分布:ggExtra与ggpairs实战指南

1. 从一张"信息量不够"的散点图说起

如果你用R画过散点图,大概率经历过这样的场景:把两组连续变量丢进plot()或者ggplot2的geom_point(),出来的图能看,但总觉得少了点什么。点云堆在一起,你能看出大致趋势,却说不清每个变量各自的分布形态——它是对称的?右偏的?有没有双峰?有没有离群值扎堆?这些信息,单靠一张散点图根本读不出来。

我最初做数据分析时也踩过这个坑。有一次帮朋友看一批实验数据,两个指标之间的散点图看起来"有点正相关",但相关性到底强不强、是不是被几个极端点带偏了,光看图完全没底。后来我在图上补了边际分布——也就是在散点图的顶部和右侧各加一个单变量分布图——整张图的信息密度瞬间上了一个台阶。散点看关系,边际看分布,一张图把两个维度的信息全讲清楚了。

这就是散点图结合边际分布图(scatter plot with marginal distributions)的价值所在。它本质上是一个"联合分布的可视化":主图展示两个变量的联合关系,边际图展示各自的边缘分布。在R里实现这个效果,路子不止一条,从最朴素的layout()拼图,到ggplot2生态里的ggExtra、cowplot、patchwork,再到GGally的ggpairs(),各有各的适用场景和坑。

这篇内容我会把几条主流路线都拆开讲清楚:为什么边际分布值得加、底层是怎么拼出来的、每种方案的具体代码怎么写、参数怎么调、实测中会遇到哪些意外。适合已经会用R画基础图、想进一步提升图表表达力的朋友,也适合刚接触ggplot2、想找一个完整案例练手的新手。全程用虚构的模拟数据演示,代码可以直接复制运行。

2. 边际分布图到底解决了什么问题

2.1 散点图的"信息盲区"在哪里

散点图的核心任务是展示两个连续变量之间的关系:线性、非线性、聚集、离散、异常点。但它的短板也很明显——它把每个变量的分布信息压缩掉了。举个例子,同样一条从左下到右上的点云,可能来自完全不同的数据生成过程:一种是两个变量都近似正态、相关性中等;另一种是一个变量严重右偏、另一个变量在某个区间高度聚集。这两种情况在散点图上可能长得差不多,但业务含义天差地别。

边际分布图的作用,就是把被压缩掉的那部分信息"还原"出来。顶部放X轴的分布,右侧放Y轴的分布,主图依然是散点。这样你一眼就能同时读到三件事:X怎么分布、Y怎么分布、X和Y怎么一起变化。对于做探索性数据分析(EDA)的人来说,这是性价比极高的一种图。

2.2 边际图该用什么图形来画

边际分布不是只有直方图一种选择。常见的几种:

  • 直方图(histogram):最直观,能看出偏态、多峰,但对组距(binwidth)敏感,组距选不好会误导。
  • 密度曲线(density):平滑,适合看整体形态,但会掩盖一些局部结构,且核带宽(bandwidth)同样影响结果。
  • 箱线图(boxplot):极简,突出中位数、四分位和离群值,但看不出多峰。
  • 小提琴图(violin):箱线图加密度,信息量大,但小样本时形状不稳定。

我的经验是:样本量小于200时优先用直方图,因为密度曲线在小样本下会画出很"假"的平滑形状;样本量大、想强调形态时用密度曲线;只想快速看离群值时用箱线图。ggExtra默认给的是直方图,但可以通过参数换成箱线图或密度图,后面会讲。

2.3 什么场景下值得加边际分布

不是所有散点图都需要边际。以下几种情况我强烈建议加上:

  • 变量分布本身是分析重点:比如你要论证某个指标右偏严重,边际直方图就是直接证据。
  • 点云重叠严重、趋势不明显:边际分布能帮你判断是不是因为某个变量方差太大导致散点糊成一团。
  • 要向非技术读者汇报:一张图讲完三件事,比放三张图更省事,也更不容易让人走神。
  • 做异常值排查:边际图上的离群点往往对应主图里的极端散点,两边对照着看,定位很快。

反过来,如果两个变量都是均匀分布、关系又非常清晰,加边际图就是画蛇添足,反而增加视觉负担。

3. 用ggExtra给ggplot2散点图"长出"边际分布

3.1 为什么首选ggExtra

在R的边际散点图方案里,ggExtra是我用得最多的一个。原因很简单:它和ggplot2是无缝衔接的。你正常写一个ggplot散点图对象,把它丢进ggMarginal(),边际图就自动加上了。不需要手动算密度、不需要拼图、不需要调坐标轴对齐。对于已经熟悉ggplot2语法的人来说,学习成本几乎为零。

它的底层逻辑其实不复杂:ggMarginal()接收一个ggplot对象,提取其中的数据映射,然后在主图的上方和右侧(或左侧、下方)分别绘制单变量图,最后用grid图形系统把它们拼成一个整体。所以你看到的"边际图"本质上是三个独立图形被精确对齐后组合的结果。

3.2 最小可运行示例

先造一批模拟数据。这里我用两个相关的正态变量,故意让X稍微右偏一点,方便后面观察边际形态:

set.seed(42) n <- 500 x <- rnorm(n, mean = 50, sd = 12) + rexp(n, rate = 0.5) y <- 0.6 * x + rnorm(n, mean = 20, sd = 10) df <- data.frame(x = x, y = y)

安装并加载包(如果还没装):

# install.packages("ggplot2") # install.packages("ggExtra") library(ggplot2) library(ggExtra)

画主图,然后套上边际:

p <- ggplot(df, aes(x = x, y = y)) + geom_point(alpha = 0.5, color = "#2c7fb8", size = 1.8) + geom_smooth(method = "lm", se = FALSE, color = "#d95f02", linewidth = 0.8) + theme_minimal(base_size = 13) + labs(x = "指标 X", y = "指标 Y") p_marg <- ggMarginal(p, type = "histogram", fill = "#a6bddb", color = "white") p_marg

跑完这段,你会得到一张主图带回归线、顶部和右侧各有一个直方图的组合图。type = "histogram"是默认值,fill和color控制边际直方图的填充和边框。

3.3 type参数:直方图、密度、箱线、小提琴怎么选

ggMarginal()的type参数支持四种取值,实测下来各有脾气:

type取值适用场景注意事项
"histogram"小样本、想看偏态和多峰组距由binwidth控制,默认自动,建议手动调
"density"大样本、强调平滑形态小样本会画出假峰,慎用
"boxplot"快速看中位数和离群值信息量最少,但最干净
"violin"想同时看分布和分位数小样本形状不稳定

切换很简单,改一个参数就行:

ggMarginal(p, type = "density", fill = "#a6bddb", alpha = 0.6) ggMarginal(p, type = "boxplot", size = 8, fill = "#a6bddb")

这里有个细节:boxplot和violin类型下,size参数控制的是箱体或小提琴的粗细,不是点的大小。我第一次用的时候没注意,调了半天以为没生效。

3.4 让X和Y用不同的边际类型

ggMarginal()允许X轴和Y轴分别指定类型,通过type传一个列表:

ggMarginal( p, type = list(x = "histogram", y = "boxplot"), fill = "#a6bddb" )

这个功能在变量性质不同时特别有用。比如X是连续测量值、想看分布形态,Y是分组均值、只想看离群值,就可以X用直方图、Y用箱线图。不过要注意,列表里的名字必须是x和y,写成别的会报错。

3.5 分组着色:边际图也能跟着分组走

如果你的散点图按某个因子分了组(比如color = group),ggMarginal()也能让边际图跟着分组。但这里有个坑:默认情况下边际图不会自动继承分组,你需要显式设置groupColour = TRUE和groupFill = TRUE:

df$group <- factor(sample(c("A", "B"), n, replace = TRUE)) p2 <- ggplot(df, aes(x = x, y = y, color = group)) + geom_point(alpha = 0.6, size = 1.8) + theme_minimal(base_size = 13) ggMarginal(p2, type = "density", groupColour = TRUE, groupFill = TRUE, alpha = 0.4)

实测下来,分组边际图在比较两组分布差异时非常好用——主图看两组关系是否一致,边际看两组各自的分布是否重叠。但要注意,分组多了(超过3组)边际图会变得很花,这时候建议改用分面(facet)而不是叠色。

4. 不依赖ggExtra:用cowplot和patchwork手动拼装

4.1 什么时候需要手动拼

ggExtra虽然方便,但有两个限制:一是边际图的样式定制空间有限,二是它返回的是一个gtable对象,后续想再叠加图层或调整布局比较麻烦。如果你需要完全控制边际图的外观,或者想把边际图换成更复杂的图形(比如带注释的密度图、带均值线的直方图),手动拼装就更灵活。

手动拼装的思路很直接:分别画主图、顶部图、右侧图,然后用拼图包把它们按"上-左-右"的布局组合起来。核心难点在于坐标轴对齐——顶部图的X轴范围必须和主图一致,右侧图的Y轴范围必须和主图一致,否则拼出来会错位。

4.2 用cowplot拼装的完整流程

cowplot的plot_grid()和axis_canvas()是手动拼边际图的经典组合。axis_canvas()专门用来生成和主图坐标轴对齐的边际图。

library(cowplot) # 主图 main_plot <- ggplot(df, aes(x = x, y = y)) + geom_point(alpha = 0.5, color = "#2c7fb8", size = 1.8) + theme_minimal(base_size = 13) + theme(plot.margin = margin(5, 5, 5, 5)) # 顶部边际图:用axis_canvas对齐X轴 x_density <- axis_canvas(main_plot, axis = "x") + geom_density(aes(x = x), data = df, fill = "#a6bddb", alpha = 0.7) # 右侧边际图:用axis_canvas对齐Y轴,注意需要coord_flip y_density <- axis_canvas(main_plot, axis = "y", coord_flip = TRUE) + geom_density(aes(x = y), data = df, fill = "#a6bddb", alpha = 0.7) + coord_flip() # 拼装 combined <- insert_xaxis_grob(main_plot, x_density, grid::unit(0.8, "null"), position = "top") combined <- insert_yaxis_grob(combined, y_density, grid::unit(0.8, "null"), position = "right") ggdraw(combined)

这段代码的关键在axis_canvas():它会自动读取主图的坐标轴范围,生成一个宽度(或高度)匹配的画布,你在上面画的密度图就会自动对齐。insert_xaxis_grob()和insert_yaxis_grob()负责把边际图插到主图的对应位置。grid::unit(0.8, "null")控制边际图占整体布局的比例,调大调小就能改变边际图的厚度。

4.3 用patchwork拼装的更简写法

如果你觉得cowplot的axis_canvas太绕,patchwork提供了更直观的拼图语法。但patchwork本身不解决坐标轴对齐问题,需要手动设置scale和theme来保证对齐:

library(patchwork) top_plot <- ggplot(df, aes(x = x)) + geom_histogram(bins = 30, fill = "#a6bddb", color = "white") + theme_void() right_plot <- ggplot(df, aes(x = y)) + geom_histogram(bins = 30, fill = "#a6bddb", color = "white") + coord_flip() + theme_void() # 用空图占位,构造"上-左-右"布局 layout <- (top_plot + plot_spacer()) / (main_plot + right_plot) + plot_layout(widths = c(4, 1), heights = c(1, 4)) layout

patchwork的写法更符合直觉,但要注意:theme_void()会去掉所有坐标轴,这样边际图和主图之间不会有冗余的轴线干扰。如果你想让边际图保留刻度,就得手动调theme,并且确保X轴范围和主图一致——这一步很容易出错,建议先用theme_void()跑通,再逐步加细节。

4.4 两种拼装方案的取舍

维度cowplot + axis_canvaspatchwork
坐标轴对齐自动手动
代码复杂度中等低
样式定制自由度高高
适合场景需要精确对齐、复杂边际图快速拼装、简单边际图

我的建议是:如果只是想要一个标准的边际散点图,直接用ggExtra;如果需要定制边际图的每一个细节,用cowplot;如果只是想快速拼一个能看的版本,用patchwork。三条路都跑一遍,你就知道哪种最顺手了。

5. GGally的ggpairs:多变量场景下的边际分布矩阵

5.1 ggpairs解决的是另一个问题

前面讲的都是"两个变量"的边际散点图。但实际分析中,你往往要同时看四五个变量的关系。这时候一张一张画散点图效率太低,GGally包的ggpairs()就是为这个场景设计的:它生成一个散点图矩阵,对角线放单变量分布,下三角放散点图,上三角放相关系数。本质上,它是"边际分布"思想在多变量上的推广。

# install.packages("GGally") library(GGally) ggpairs( df[, c("x", "y")], upper = list(continuous = "cor"), lower = list(continuous = "smooth"), diag = list(continuous = "densityDiag") )

这段代码会生成一个2x2的矩阵:对角线是密度图,左下是带平滑线的散点图,右上是相关系数。变量多了以后,这个矩阵能让你在几秒内扫完所有两两关系。

5.2 对角线与上下三角的配置

ggpairs()的upper、lower、diag三个参数分别控制上三角、下三角和对角线的图形类型。常用的取值:

  • 对角线:"densityDiag"(密度)、"barDiag"(直方图)、"boxDiag"(箱线)
  • 下三角:"points"(纯散点)、"smooth"(散点加平滑)、"smooth_loess"(LOESS平滑)
  • 上三角:"cor"(相关系数)、"points"(散点)

一个我常用的配置是:对角线用直方图(比密度图更实在),下三角用带平滑的散点,上三角显示相关系数。这样一张图里既有分布、又有关系、还有量化指标。

5.3 ggpairs在变量多时的性能坑

ggpairs()有个很现实的问题:变量一多就慢,而且图会糊。我试过8个变量,生成的矩阵图在小屏幕上根本看不清,每个子图只有指甲盖大小。而且它的渲染是逐个子图画的,变量数n对应的子图数是n²,8个变量就是64个子图,跑起来明显卡顿。

我的经验是:ggpairs()适合3到5个变量的快速概览。超过5个,要么分批看,要么改用相关性热图加少量关键散点图的组合。另外,如果数据量很大(比如超过1万行),ggpairs()的散点会严重过绘,建议先抽样再画,或者把下三角改成"smooth"并设置alpha。

5.4 和单变量边际散点图的分工

ggpairs()和ggExtra不是替代关系,而是分工关系。我的用法是:先用ggpairs()做全局扫描,锁定几对值得深挖的变量,再用ggExtra对每一对画精细的边际散点图。前者负责"发现",后者负责"呈现"。这个流程在探索性分析里非常高效,比一上来就死磕单张图要快得多。

6. 实测中那些文档不会告诉你的细节

6.1 边际直方图的组距是个隐形陷阱

ggExtra的边际直方图默认组距是自动计算的,但自动值经常不理想。我遇到过好几次:数据明明有双峰,默认组距太宽,两个峰被糊成一个;或者组距太窄,直方图看起来像锯齿。解决办法是显式传binwidth:

ggMarginal(p, type = "histogram", binwidth = 3, fill = "#a6bddb")

binwidth的值需要根据数据范围试。一个经验公式:先用diff(range(df$x)) / 30估一个初始值,再上下调两次看效果。别嫌麻烦,边际图的组距直接决定读者对分布的判断,值得多花两分钟。

6.2 主图和边际图的颜色要协调

很多人画边际图时只顾主图好看,边际图随便给个颜色,结果整张图看起来像两个不相关的图拼在一起。我的做法是:边际图的填充色用主图点颜色的浅色版本,边框用白色或透明。这样视觉上是一体的。如果主图用了分组色,边际图也一定要跟着分组,否则读者会困惑"这个边际图到底对应哪一组"。

6.3 坐标轴范围不一致导致的错位

手动拼装时最常见的坑就是坐标轴没对齐。cowplot的axis_canvas能自动对齐,但如果你用的是patchwork,必须手动保证顶部图的X轴范围和主图完全一致。一个可靠的检查方法是:拼完之后看主图最左和最右的点,是否正好落在顶部直方图的两端。如果顶部直方图比主图宽或窄,就是范围没对上。

6.4 导出图片时的尺寸问题

边际散点图因为多了一块,导出时如果还用默认尺寸,边际图会被压得很扁。我的习惯是:导出宽度比普通散点图增加30%左右,高度增加20%。比如普通图导出6x5英寸,边际图就导8x6英寸。用ggsave()时显式指定:

ggsave("marginal_scatter.png", p_marg, width = 8, height = 6, dpi = 300)

另外,ggMarginal()返回的对象用ggsave()保存时,有时候会出现边际图和主图间距异常的问题。如果遇到,改用png()+print()+dev.off()的方式保存,通常更稳。

6.5 中文标签的字体坑

如果你的轴标签是中文,在部分系统上导出PDF或PNG时会出现方框乱码。这不是ggExtra的问题,是图形设备字体配置的问题。稳妥的做法是在画图前指定字体:

library(showtext) showtext_auto()

或者导出时用支持中文的图形设备。这个坑和边际图本身无关,但因为它经常在"图快画完了"的时候冒出来,所以提前处理掉能省不少事。

7. 一套可以直接抄的完整工作流

把前面所有内容串起来,我给一个从数据到成图的完整流程,你可以直接改成自己的数据用。

第一步,准备数据并做基本检查:

set.seed(123) n <- 600 df <- data.frame( x = rnorm(n, 50, 12) + rexp(n, 0.4), y = 0.5 * rnorm(n, 50, 12) + rnorm(n, 25, 8) ) summary(df)

第二步,先画一张不带边际的散点图,确认趋势和异常点:

library(ggplot2) p_base <- ggplot(df, aes(x, y)) + geom_point(alpha = 0.45, size = 1.6, color = "#2c7fb8") + geom_smooth(method = "lm", se = FALSE, color = "#d95f02", linewidth = 0.8) + theme_minimal(base_size = 13) + labs(x = "指标 X", y = "指标 Y") p_base

第三步,加上边际分布,先试直方图:

library(ggExtra) p_final <- ggMarginal( p_base, type = "histogram", binwidth = 4, fill = "#a6bddb", color = "white", size = 1 ) p_final

第四步,如果直方图形态不理想,换密度图对比:

p_density <- ggMarginal( p_base, type = "density", fill = "#a6bddb", alpha = 0.6, size = 1 ) p_density

第五步,导出:

ggsave("final_marginal.png", p_final, width = 8, height = 6, dpi = 300)

这套流程我用了很多次,基本能覆盖80%的边际散点图需求。剩下的20%是特殊定制,比如分组着色、混合边际类型、手动拼装,前面各节都讲过了,按需取用。

最后分享一个我自己的判断标准:如果一张散点图你看完之后,还需要再单独画一张直方图才能说清楚分布,那就说明它该加边际了。边际分布不是为了让图"看起来高级",而是为了在一张图里把联合信息和边缘信息同时讲清楚。想明白这一点,你就知道什么时候该用、什么时候不该用了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进