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 groupggMarginal()也能让边际图跟着分组。但这里有个坑默认情况下边际图不会自动继承分组你需要显式设置groupColour TRUE和groupFill TRUEdf$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)) layoutpatchwork的写法更符合直觉但要注意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_loessLOESS平滑上三角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的边际直方图默认组距是自动计算的但自动值经常不理想。我遇到过好几次数据明明有双峰默认组距太宽两个峰被糊成一个或者组距太窄直方图看起来像锯齿。解决办法是显式传binwidthggMarginal(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%是特殊定制比如分组着色、混合边际类型、手动拼装前面各节都讲过了按需取用。最后分享一个我自己的判断标准如果一张散点图你看完之后还需要再单独画一张直方图才能说清楚分布那就说明它该加边际了。边际分布不是为了让图看起来高级而是为了在一张图里把联合信息和边缘信息同时讲清楚。想明白这一点你就知道什么时候该用、什么时候不该用了。