☰
3D直方图分组绘制详解:R语言科研绘图从数据处理到出图
2026/10/16 13:12:07 网站建设 项目流程

1. 3D直方图-分组,到底是个啥

这一期我们聊一个科研绘图里非常容易让人“一是看不懂、二是用不对”的图形:3D直方图-分组。很多人在二维直方图里做得挺熟练,一旦数据里多了一个分组变量,就不知道该怎么把三组或者四组的频率分布同时放在一张图里展示,画出来不是糊成一团,就是根本看不出组间差异。

先说明白什么叫“3D直方图”。普通二维直方图把横轴切成一格一格的区间,纵轴表示落在每个区间里的样本个数;三维直方图则多了一个维度:横轴上切分一个连续变量,纵轴上再切分另一个连续变量,每个网格里的样本数就变成高度,用竖向柱体的高度表达频数。把整个频率矩阵渲染成一个上下起伏的曲面或柱状阵列,就得到了3D直方图。

那“分组”两个字加进来后,含义就更明确了:除了x、y两个连续变量之外,还有一个分类变量,比如处理组和对照组、雌性和雄性、不同品系或不同时间点。我们要在同一套坐标系里或者分面板的方式下,把各个组的二维频率分布同时呈现出来,用来直观比较组间差异。

它适合谁来学?最适合两类人:一类是刚入门、想系统掌握科研绘图的硕博生,以后投论文、做组会汇报都用得上;另一类是已经画了很多散点图、热图但总觉得表达不够直观的研究人员,尤其是你的样本量很大、散点图会互相压盖的时候,3D直方图能帮助你从“点云”里把密度分布拎出来看。

我还得说明白一个容易踩坑的前提:3D直方图不是万能的,它适合组数少、样本量大的情况。如果样本量只有几十个点,画出来会是一堆孤零零的柱子,反而不如实观感好的二维分面直方图。如果分组超过四组,堆在一个三维空间里也会乱到怀疑人生。合理的用法是两到三组对比,或者用一组主图加若干分面板。

2. 作图画前先想清楚:两条技术路线怎么选

R 里能做 3D 直方图的包很多,但真正适合科研场景的其实就两条路线。我先把它们核心差异摆出来。

路线代表方式优势劣势
静态出版图路线plot3D包的hist3D()可控性强,配色、角度、边框、坐标轴全部精确设置;分辨率高,可以直接投期刊不交互;需要手动先把频率矩阵算好
交互探索路线plotly包的histogram2d一键生成三维交互图,可旋转缩放,适合答辩和主页展示默认风格偏“网页感”,细化参数不如plot3D灵活;投稿截图有时不够正式

有些人可能会提到scatterplot3d,它本质上是散点图,画出 3D 直方图需要自己在点阵里画线,灵活但太麻烦了,不太推荐科研出图用。还有rgl,交互能力强,但窗口操作在论文导出环节一直是个痛点,图形保存成静态图后颗粒感偏重,所以我个人很少推荐给新手。

我的私人建议很简单:给论文用的图,走plot3D;给组会、毕业答辩、项目网站用的图,走plotly。如果你时间紧,也可以先用plotly快速预览分组分布长什么样,确认视觉感受后再用plot3D精修出版版本。这样既能保留探索时的悟性,也不耽误最终出图质量。

还要强调一点,无论走哪条路线,核心工作都不在绘图函数本身,而是数据处理。3D 直方图画的本质上是一个二维频数矩阵,矩阵没构建好,后面的一切都白搭。下一节专门讲这一步,这也是大多数人画不好的真正原因。

3. 数据处理:分箱、计数、矩阵化

很多教程上来就一行hist3D(...),代码看起来简单,但自己套数据时才发现完全画不出来,原因就是没有把数据从“原始数据框”转换成“分组频数矩阵”。这一步是核心中的核心,我建议所有人在动手前先把这一步吃透。

3.1 数据结构与模拟数据

为了演示,我构造一份模拟数据。场景是:某个实验有对照组和处理组,每组各 500 个样本,记录了两个连续指标x和y。

set.seed(202407) n <- 500 ctrl_x <- rnorm(n, 0, 1.2) ctrl_y <- rnorm(n, 0, 1.2) trt_x <- rnorm(n, 1.5, 1.0) trt_y <- rnorm(n, 0.8, 1.0)

真实的科研数据往往没有这么规矩,可能长这样:

df <- data.frame( group = rep(c("Control", "Treatment"), each = n), x = c(ctrl_x, trt_x), y = c(ctrl_y, trt_y) )

结构不复杂,就是一行一个样本,group列标记分组。记住,大部分 R 绘图函数需要这种“长格式”,如果你手里是一张宽表,比如每组各占一列,那就得先做数据整形,你可以用tidyr::pivot_longer()把它变成上面这种格式。

3.2 分箱策略

所谓分箱,就是决定把 x 轴和 y 轴切成多少个区间。这里有个非常容易被忽略的坑:分组比较时,两个组必须用完全相同分箱边界。切开的区间都不一样,频数就没有可比性,画出来的图看着像两组差异,实际上是分箱不均造成的。

我常用的做法是先把几组数据的范围放到一起看,再统一设定边界:

bx <- seq(-5, 6, by = 0.5) by <- seq(-5, 6, by = 0.5)

这里by = 0.5表示每个格子宽度 0.5,一共 22 个区间左右。具体选多少看你的数据分布:分箱太粗,峰会被抹平;分箱太细,柱子参差不齐,噪声很大。我的经验是:把格子数量控制在 10 到 30 个之间,先画一张看形状,再微调步长。如果你实在没头绪,可以用 R 自带的nclass.Sturges()得到建议区间数,再据此等分。

注意边界处理:cut()函数默认左开右闭,数据刚好落在边界上时,如果没有include.lowest = TRUE,最小值可能会变成NA,画图时会出现断档。所以我在分割时都会把include.lowest = TRUE加上,确保最小的那个值也能进入第一个区间。

3.3 生成分组计数矩阵

这一步最关键。很多人直接对每组使用table(),然后传入hist3D(),但table()结果经常因为某些区间没有样本,导致矩阵缺行缺列,图形就会错位。我的标准写法是:预先创建一个全零矩阵,再把table()得到的结果填进去。

count_matrix <- function(x, y, bx, by) { xi <- cut(x, breaks = bx, include.lowest = TRUE) yi <- cut(y, breaks = by, include.lowest = TRUE) tab <- table(xi, yi) m <- matrix(0, nrow = length(bx) - 1, ncol = length(by) - 1, dimnames = list( levels(xi), levels(yi) )) m[rownames(tab), colnames(tab)] <- tab m } m_ctrl <- count_matrix(ctrl_x, ctrl_y, bx, by) m_trt <- count_matrix(trt_x, trt_y, bx, by)

这个函数里的核心逻辑要读清楚:table(xi, yi)只会记录有样本的格子,那些零频数的格子会自动缺席。全零矩阵则是把所有格子都铺好,维度严格等于length(bx)-1行、length(by)-1列。然后用行列名去索引填充,就能保证每个格子都有值。

做完这一步,m_ctrl和m_trt就是两个形状完全相同的频数矩阵,后面的绘图只是“最后一公里”。

4. 分组 3D 直方图实操:三种常用方案

下面给出三套能直接跑的方案。第一套是双面板静态图,适合投稿;第二套是同一三维空间叠加,适合直接展示组间对比;第三套是交互式版本,适合答辩现场。三套方法我都实际用过,不藏私,全部把细节说出来。

4.1 方案一:hist3D双面板并排,稳妥的投稿图

这个方案的技术思路是:两组数据用相同角度、相同配色体系的子图并排展示,读者一眼就能看到两组分布形状的不同。适合的期刊场景是“对照组 vs 处理组”“两种处理方法对比”这类比较简单的分组。

library(plot3D) # 计算 x、y 每个箱子的中点坐标,用于绘图时定位 mid_x <- (head(bx, -1) + tail(bx, -1)) / 2 mid_y <- (head(by, -1) + tail(by, -1)) / 2 png("hist3d_two_panel.png", width = 12, height = 6, units = "in", res = 300) par(mfrow = c(1, 2), mar = c(2, 2, 2, 2)) hist3D(x = mid_x, y = mid_y, z = m_ctrl, theta = 40, phi = 20, col = "steelblue3", border = "black", xlab = "x", ylab = "y", zlab = "Frequency", main = "Control", ticktype = "detailed") hist3D(x = mid_x, y = mid_y, z = m_trt, theta = 40, phi = 20, col = "tomato3", border = "black", xlab = "x", ylab = "y", zlab = "Frequency", main = "Treatment", ticktype = "detailed") dev.off() par(mfrow = c(1, 1))

我把theta = 40, phi = 20作为默认视角。theta是绕 z 轴旋转的角度,phi是俯仰角。这个组合比较通透,几乎所有数据都能看到全貌。如果换了数据还觉得有柱子挡着看不到后面的峰,theta可以试着在 30 到 60 之间调整,别用太大的角度,否则 3D 柱子会变形得厉害。

这里的关键细节是矩阵的行列方向和坐标系对应关系。hist3D()中,z矩阵的行对应x方向,列对应y方向。所以我的bx控制行方向,by控制列方向,m_ctrl的行数等于length(bx)-1,列数等于length(by)-1。很多报错都是因为把行列顺序搞反了,矩阵转置一下就好了。

4.2 方案二:同一三维空间叠加上色,视觉冲击力最强

如果你不想分成两个面板,而是希望把两组数据画在同一个三维坐标系里,用颜色区分,这种方法更适合组会汇报。核心思路是调用hist3D(..., add = TRUE)把第二组叠加到第一组上。

png("hist3d_overlay.png", width = 8, height = 6, units = "in", res = 300) hist3D(x = mid_x, y = mid_y, z = m_ctrl, theta = 45, phi = 25, col = "steelblue3", border = "grey40", xlab = "x", ylab = "y", zlab = "Frequency", main = "Control vs Treatment", ticktype = "detailed") hist3D(x = mid_x + 0.8, y = mid_y, z = m_trt, theta = 45, phi = 25, col = "red3", border = "grey40", add = TRUE) legend("topright", legend = c("Control", "Treatment"), fill = c("steelblue3", "red3")) dev.off()

这里我做了个小技巧:x = mid_x + 0.8把处理组的整个直方图沿着 x 轴平移了 0.8 个单位。这样两组的峰不会完全叠在一起,视觉上“错开并排”的感觉很强,又保持了同一坐标系的对比优势。平移量不能太大,否则 x 轴的刻度含义会被干扰,我建议控制在单个箱子宽度的 0.5 到 1.5 倍之间。

这种叠加式图有个天然的缺点:后面的柱子容易被前面的柱子遮挡。我自己的解决方法有两个:一是提高phi,让视角更俯视一些,减少遮挡;二是把border颜色调成灰色而不是纯黑,降低线条的干扰性。如果真的遮挡严重,那还是回到方案一的双面板更稳妥。

4.3 方案三:交互式plotly版本,答辩和网站展示利器

交互式的优势非常直观:读者可以自己转动视角,想看哪面看哪面。代码量反而比plot3D更少,因为不需要手动构建频数矩阵,直接喂原始数据就行。

library(plotly) fig <- plot_ly() |> add_trace(x = ctrl_x, y = ctrl_y, type = "histogram2d", colorscale = list(c(0, 1), c("white", "steelblue")), opacity = 0.7, showscale = FALSE, name = "Control") |> add_trace(x = trt_x, y = trt_y, type = "histogram2d", colorscale = list(c(0, 1), c("white", "red")), opacity = 0.7, showscale = FALSE, name = "Treatment") |> layout(scene = list( xaxis = list(title = "x"), yaxis = list(title = "y"), zaxis = list(title = "Frequency") )) fig

这个版本的图交互时能看到两组频率柱体在三维空间中“缠绕”在一起。注意opacity = 0.7这个参数非常关键,如果不设透明度,后面一组会被前面一组完全盖住;设得太低,颜色又会显得很淡。我调了几十次之后,觉得 0.6 到 0.75 是视觉上最舒服的区间。

如果你是在 R Markdown 或 Quarto 里用,直接渲染后就是可交互的 HTML;如果你想导出静态图,可以用htmlwidgets::saveWidget()保存为网页文件,再用浏览器的截图功能截取你要的角度。但要提醒一句:交互图截图投稿,像素质量一般不够,最终投稿还是用plot3D那套。

5. 细节决定档次:配色、视角、坐标轴、图例与导出

很多人画 3D 直方图,图里的信息没错,但就是显得“土”,主要输在细节上。我把自己调图的顺序整理成一个流程,照着走一遍,效果立刻会不一样。

5.1 颜色映射

plot3D里的col参数看起来只是填一个颜色,但其实它可以接受一整组渐变色。我建议科研图尽量用双色渐变,而不是单一纯色。比如蓝色系可以用colorRampPalette(c("white", "steelblue", "navy"))(100),这样低频率区和高峰值区有明暗过渡,视觉层次更丰富。

mycol <- colorRampPalette(c("white", "steelblue", "navy"))(100) hist3D(x = mid_x, y = mid_y, z = m_ctrl, theta = 40, phi = 20, col = mycol, border = NA, ticktype = "detailed")

注意我在这里把border = NA了。如果格子特别多,黑色边框会变成密密麻麻的网格线,把整个图切碎。当格子数超过 20×20 的时候,建议去掉边框或改成浅灰色细线;格子数少则可以保留黑色边框,柱体边缘清晰,更接近传统直方图观感。

5.2 视角与坐标轴

theta和phi是决定 3D 图第一印象的参数。theta控制水平旋转,phi控制俯仰角度。我自己习惯的调试思路是:先不断转动鼠标(或重复运行函数)观察哪个视角能同时看到最大峰和次大峰,然后固定下来。通常theta在 35 到 55 度之间,phi在 15 到 35 度之间。

坐标轴标签方面,加入xlab、ylab、zlab是基本操作。再加一个ticktype = "detailed",就会在三个坐标轴上显示具体刻度值,这样别人能从图里直接读出某个峰大约在什么位置、频数大约是多少。否则只有三个大方向箭头,交论文时会被审稿人嫌弃信息量不足。

5.3 图例与导出

双面板图一般不需要单独图例,因为主标题已经写清楚了。但叠加图必须有图例。

plot3D的图例不能利用hist3D()内部的参数直接生成,我的笨办法是像上文那样用legend()手动加,注意坐标选在图外右上角。如果mar留白不够,图例可能会跑到画布外,导出时被切掉,所以一定要先把边距留够。

导出设置我这里再重复一次,因为真的很重要。投稿图一般需要 300 dpi 以上。我用的是:

png("hist3d_final.png", width = 9, height = 6, units = "in", res = 300)

宽度 9 英寸,高度 6 英寸,比例接近 3:2,视觉上很稳。如果图里有大量文字说明,宽度可以适当加到 10。

6. 常见问题与排查技巧实录

做 3D 直方图以来,我在实际使用和给周围同学答疑时遇到过不少报错和视觉问题,挑几个出现频率最高的整理成速查表,希望大家不用重复踩坑。

现象原因解决方法
报错“错误: 维数不对”hist3D的 z 矩阵行列数与 x、y 长度不匹配检查nrow(z)是否等于length(x),ncol(z)是否等于length(y)
图中某块区域突然空掉cut()把边界值变成了NA加上include.lowest = TRUE;清理NA数据
两组图的坐标范围不一致分箱边界bx、by没有统一两组必须共用一个bx、by向量
柱子颜色全部一样,看不出高低col参数传的是单一颜色改用渐变色向量,或检查zlim设置
后面的柱体完全被挡住视角不好或透明度过低调整theta、phi;plotly中增加opacity
保存的 png 图字太小或者发虚分辨率设置不对用res = 300;同时调大width、height
图例被裁掉边距不够增大mar向量,或手动调整legend坐标

除了这些,有一个非常隐蔽的问题是table()结果中因子水平的顺序。因为cut()返回因子,因子水平的默认顺序是区间从小到大的字典序或数值序。如果分组数据里的 x 值范围差异大,两组在同一个分箱下,因子水平仍然是一致的,一般不会出错。但如果你手动构建分组矩阵,我强烈建议像我那样先显式指定dimnames,避免行名顺序错位导致数据错位。

还有一个我在实战中深有体会的性能问题:当样本量上百万、格子数也特别多时,hist3D的绘制会非常卡顿,甚至需要几十秒。遇到这种情况,我一般是先降低箱子数量到 15×15 左右做探索分析,确认分布形态后再考虑是否细化。大样本下分组直方图的意义是“看清密度轮廓”,而不是追求每一根柱子的精确度。

7. 几点额外的实战心得

写到最后,分享几个不常出现在教程里,但确实能让图更好用的经验。

第一是不要害怕把分组直方图做成“探索工具”而不是最终成品。我经常先把数据丢进plotly里转一圈,看看两组在三维空间的形状有没有明显差别,心里有数了再去精修静态图。这比一开始就在plot3D里调半天参数效率高得多。

第二是当分组数量超过两个时,我建议你克制住“一个图全画完”的欲望。三个组叠加在同一个三维空间,虽然颜色可以区分,但阅读负担会急剧上升。我试过很多次,最后都改成“一张主图 + 一张分面图”的方案:主图选两个重点组做对比,分面图再按每个组单独展示。这样既保留了对比,也照顾到每个组的细节。

第三是配色尽量选色盲友好的颜色。Control 用蓝色、Treatment 用红色对比看起来直观,但对红绿色盲用户并不友好。我更推荐“蓝 vs 橙”的组合,对比度足够,同时几乎不受色觉障碍影响。如果你做的是多个分组,可以考虑RColorBrewer里的Set2或Dark2调色板,它们是专门为分组数据设计的,颜色之间不打架。

最后再给一个小技巧:如果你希望 3D 直方图的 Z 轴表示“密度”而不是“频数”,可以在矩阵构建时除以总样本量。尤其当两组样本量不相等时,频数无法直接对比,密度值才是公平的。实现起来很简单,矩阵构造完以后除以sum(m)就行。这一步虽然小,但在很多组间比较的科研场景里,却是决定结论是否可靠的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询