我一直跟身边搞数据分析的朋友说,拿到数据先别急着画柱状图和折线图。柱状图适合对比离散分类,折线图适合看趋势,可你要是想搞清楚“数据到底是怎么分布的”“不同组之间的分布差异有多大”“一段时间内的总量是怎么累积的”,这三类图往往是更合适的选择:密度分布图、面积图、密度图。它们都属于科学统计图表里很常用的基础类型,但在R里画法和适用场景差异很大。这篇就专门把这三张图讲透:什么时候用、底层原理是什么、代码怎么写、出图后怎么调,以及我踩过的那些坑。
适合看这篇的朋友:正在用R做数据分析、写论文配图、做业务报表的人,尤其是对ggplot2有一定基础但还想把分布类图表画得更专业的人。如果你只是刚装好RStudio,跟着操作也能跑通,我会把每一步都拆开讲。
1. 图表定位:三种图分别解决什么问题
很多初学者会把“密度分布图”和“密度图”当成同一个东西,实际上在R的语境里,它们确实同源,但侧重点不一样。而面积图更是经常被误解成“折线图填了个颜色”,这个理解不能说错,但太浪费它的能力了。
1.1 密度分布图:把分布形态变成一条光滑曲线
密度分布图的核心是核密度估计,我习惯叫KDE。它的作用,是把一组连续型变量的分布形态,用一条平滑的曲线表示出来。比如你有一批用户年龄数据,只算平均值和中位数,信息损失太大;画直方图呢,又受组距影响很大,组距设成5和设成10,形状可能完全不一样。密度图就相当于对直方图做了一次平滑处理,曲线越高,说明这个取值附近的数据越密集。
我在实际项目里最常用它做三件事:看数据是否近似正态分布、看数据有没有双峰或多峰结构、看不同分组的分布是否明显分离。比如某次做模拟项目X的用户画像分析,我发现付费用户和免费用户的年龄分布曲线有重叠但峰值位置明显不同,这种信息用箱线图只能看个大概,用密度分布图就非常直观。
1.2 面积图:更适合展示“量”的累积和结构变化
面积图和折线图最大的区别在于:折线图强调的是“值”的变化趋势,面积图强调的是“总量”和“结构”。如果只有一条序列,面积图和折线图传递的信息差不多,只是视觉上更有重量感。但一旦有多条序列堆叠在一起,面积图就能同时回答两个问题:整体总量在变大还是变小?各部分的占比结构如何变化?
这个特性让它特别适合做时间序列分析里的成分变化展示,比如某产品近12个月各渠道的销售额构成、某网站各流量来源的月度趋势、某实验室各实验组的样本累积量。堆叠面积图本质上就是多个面积图的纵向叠加,每一层的顶部边界代表截止到该分类的累积总量。
1.3 密度图:组间对比的利器
我这里说的密度图,特指用geom_density()画出来的、用于多组对比的平滑分布曲线。它和密度分布图的唯一区别在于,你画了一组数据,那叫密度分布图;你画了三组五组,把它们叠在一张图里互相比较,这时候大家更习惯叫它密度图。这个命名的差异不重要,重要的是你知道多组对比时的视觉纪律:需要处理透明度、描边颜色、重叠区域的辨识度等问题。
一句话总结这一章:密度分布图搞定“单组数据的分布探索”,密度图搞定“多组数据的分布对比”,面积图搞定“时间维度上的量累积与结构变化”。三者并不互斥,在一份报告里经常组合出现。
2. 核心原理与参数细节:知其所以然才能画好图
2.1 核密度估计:带宽值是怎么影响成图的
kernel density estimation的基本逻辑,是在每个数据点位置放一个核函数,然后把所有核函数叠加起来,得到整条密度曲线。你可以这么理解:每个数据点就像一颗滴入水中的墨滴,墨水会向周围扩散,扩散范围越大,颜色就越淡;把所有墨滴的扩散效果叠加在一起,就有了整片颜色的浓淡分布。
这里最关键的一个参数是带宽,也就是bw或adjust。带宽决定了每个“墨滴”的墨水扩散半径。带宽设置得太大,曲线会过于平滑,把真实的分布细节抹掉;带宽设置得太小,曲线又会毛刺丛生,出现很多虚假的峰。这就像用不同粗细的画笔去描同一段轮廓,粗笔画丢失细节,细笔画引入噪点。ggplot2里你可以用adjust参数做相对调整,默认adjust=1,数值大于1更平滑,小于1更贴合数据。我自己做探索性分析时,一般画三张图对比:adjust=0.5、1、2,挑一个既不夸张也不失真参数。
还有一点值得注意:density曲线下的面积恒为1。这意味着y轴上的数值不是百分比,而是一个概率密度值,可以大于1。很多新手看到y轴刻度是1.5或者2就以为代码写错了,其实只要曲线下的总面积等于1,就没问题。
2.2 面积图的堆积原理:position参数是灵魂
ggplot2里画面积图主要用geom_area(),它默认position="stack",也就是堆叠模式。底层原理很简单:第一条序列从0开始画,第二条序列的底部对齐到第一条的顶部,第三条再对齐到前两条的总和顶边,以此类推。每一层的y值,是前面所有层y值的累计和。
执行堆叠时,必须保证每条序列在各x位置都有完整的数值。如果你的数据在某个时间点缺失,ggplot2会默认把它当0参与累计,这就会导致面积图出现一个突然的塌陷,视觉上非常突兀。我处理这类问题时,要么用tidyr::complete()显式补齐缺失时间点并填充缺失值,要么把x轴设置为离散的因子型而非连续型。
另一个容易忽略的坑是负值。默认的position="stack"遇到负数时,图层会向下堆积,但正负值的相对关系很容易让人误读。一组数据中既有正数又有负数时,我通常会改用position="identity"或分开画两个面积图,而不是硬堆。
2.3 密度图叠加的视觉规则:透明度、描边与分面
把多条密度曲线画在同一个坐标系里,最容易出现的问题是互相遮挡。设group的映射后,ggplot2会给每个组分配不同颜色,但如果填充色不透明,后面画的组会把前面的组盖得严严实实。我的习惯是:填充色用半透明,alpha设在0.4到0.6之间;描边色用不透明的深色,大小设为0.6到0.8。这样既能看到重叠区域的混色效果,又能清晰分辨每个组的轮廓线。
如果组数超过5个,叠加就已经不实用了。这时候我会直接用facet_wrap()分面,每个小组单独一个小图,保持纵轴刻度统一,对比时看峰位置和峰高差异。或者用ggridges包画山脊图,也是分布对比的另一种优雅解法,后面会讲到。
3. 实战演练:从模拟数据到成品图的完整流程
下面我用一组模拟数据,把密度分布图、面积图、密度图的绘制过程完整走一遍。模拟数据虽然不真实,但结构上有代表性,代码可以直接改成你自己的数据。
# 加载包,如果没有安装请先 install.packages("tidyverse") library(tidyverse) library(patchwork) # 设置随机种子,保证结果可复现 set.seed(2024) # 构造模拟数据:两个实验组 + 一个对照组,每组120个样本 group_a <- rnorm(120, mean = 70, sd = 12) group_b <- rnorm(120, mean = 85, sd = 15) control <- rnorm(120, mean = 65, sd = 18) df_dist <- tibble( score = c(group_a, group_b, control), group = factor(rep(c("A组", "B组", "对照组"), each = 120), levels = c("对照组", "A组", "B组")) ) # 构造模拟数据:某产品12个月的渠道销售额 months <- seq.Date(as.Date("2024-01-01"), by = "month", length.out = 12) df_area <- tibble( month = rep(months, 3), channel = factor(rep(c("线上", "门店", "大客户"), each = 12), levels = c("线上", "门店", "大客户")), sales = c( seq(30, 65, length.out = 12) + rnorm(12, 0, 3), seq(40, 55, length.out = 12) + rnorm(12, 0, 2), seq(20, 75, length.out = 12) + rnorm(12, 0, 4) ) )3.1 画单组密度分布图:从细节到整体
我要先画对照组的分数的密度分布图,这张图用来做“摸底”。
p1 <- df_dist |> filter(group == "对照组") |> ggplot(aes(x = score)) + geom_density(adjust = 1, fill = "#4E79A7", alpha = 0.5, color = "#2C3E50", size = 0.8) + geom_rug(alpha = 0.4) + labs( title = "对照组分数密度分布", subtitle = "带宽 adjust = 1,附带地毯图", x = "分数", y = "概率密度" ) + theme_minimal(base_size = 14)这里我加了geom_rug(),也就是底部的地毯图,每一条短线代表一个实际数据点。为什么加它?因为密度曲线是平滑的,会让人错觉数据非常均匀,而地毯图能还原真实的数据疏密分布。这个组合我强烈推荐在探索阶段使用。
如果你还想在图上标出均值和若干分位数,可以用geom_vline():
p1 + geom_vline(xintercept = mean(df_dist$score[df_dist$group == "对照组"]), linetype = "dashed", color = "#E15759", size = 0.7) + annotate("text", x = mean(df_dist$score[df_dist$group == "对照组"]) + 2, y = 0.025, label = "均值", hjust = 0)密度曲线的峰值附近就是众数所在区间,均值线和峰值的位置关系可以帮你快速判断分布是否偏态。如果均值明显在峰值的右边,数据就是右偏分布,这在收入、时长类数据里很常见。
3.2 画多组对比密度图:控制好透明度
接着把三个组建在一张图里对比。这是密度图最典型的用法。
p2 <- df_dist |> ggplot(aes(x = score, fill = group, color = group)) + geom_density(adjust = 1.2, alpha = 0.45, size = 0.7) + scale_fill_manual(values = c("对照组" = "#BAB0AC", "A组" = "#4E79A7", "B组" = "#F28E2B")) + scale_color_manual(values = c("对照组" = "#6E635E", "A组" = "#2C3E50", "B" = "#D55E00")) + labs( title = "三组分数密度对比", subtitle = "半透明填充避免完全遮挡,aes 同时映射 fill 和 color", x = "分数", y = "概率密度" ) + theme_minimal(base_size = 14) + theme(legend.position = "top")这里我把fill和color同时映射到group。只映射fill,曲线轮廓全是黑色的,组间在重叠区不好区分;只映射color,填充全是黑的,且大面积色块缺失,视觉重量很弱。两者都映射,再配合半透明,是我画多组密度图最常用的配置。
有个容易翻车的地方:我上面代码里写了一个大写“B”,这是错误示范,实际上应该是"B组" = "#D55E00",否则图例会显示一个B,并警告颜色映射不完整。ggplot2里的scale_*_manual()必须严格匹配数据里的因子水平,少一个就少一段颜色。建议直接用levels(df_dist$group)来检查因子水平再写颜色向量。
3.3 画堆叠面积图:趋势与结构同时呈现
面积图的经典场景就是多分类的时间序列。我用前面构造的df_area画堆叠面积图。
p3 <- df_area |> ggplot(aes(x = month, y = sales, fill = channel)) + geom_area(position = "stack", alpha = 0.8, color = "white", size = 0.3) + scale_fill_brewer(palette = "Set2") + scale_x_date(date_labels = "%Y-%m", date_breaks = "2 months") + labs( title = "各渠道月度销售额堆叠面积图", subtitle = "顶部边界代表总销售额,分层边界代表渠道结构", x = "月份", y = "销售额(万元)" ) + theme_minimal(base_size = 14)给geom_area加上color = "white",每个堆叠层之间会出现一条白色的细缝,这在堆叠图里能明显提升层次感,尤其是分类颜色相近的时候。颜色要是太花,可以改用scale_fill_viridis_d(),那个配色方案对色盲更友好。
画面积图时,x轴必须是连续的日期或数值,不要用因子型,否则ggplot2会把每个x位置当作独立类别,堆叠逻辑虽然不变,但空间感受完全不一样。日期轴用scale_x_date()控制刻度标签,date_breaks设成“2 months”,画面不会太挤。
3.4 一张画布汇总三张图:布局编排
我用patchwork包把三个图拼在一起,用于日常汇报。注意patchwork不会自动处理三个图的图例和标题长度,但基本的对齐已经做得很好了。
combined <- (p1 + p2) / p3 + plot_annotation( title = "科学统计图表:密度分布图、面积图、密度图", theme = theme(plot.title = element_text(size = 18, face = "bold")) ) ggsave("dist_area_density.png", combined, width = 12, height = 9, dpi = 300)拼图时我习惯把单组密度分布图和对比密度图放同一行,因为它们y轴单位一致,视觉比例协调;面积图单独占一行,因为时间序列通常需要更宽的画布。如果你的实际数据差异很大,可以用patchwork的plot_layout(widths = c(1, 1))或heights参数强制控制每个panel的尺寸。
4. 进阶技巧:让图表从“能看”到“专业”
到这里基础图都能画出来了,但如果只想停在这个程度,你的图还是容易被人一眼归入“新手作品”。下面几个进阶操作,我认为投入产出比很高。
4.1 用山脊图替代多组密度图
当组数特别多,比如6个组以上,或者你要强调分布随某个连续变量的变化时,山脊图比叠加密度图清楚得多。ggridges包实现起来非常简洁:
install.packages("ggridges") library(ggridges) df_dist |> ggplot(aes(x = score, y = group, fill = group)) + geom_density_ridges(alpha = 0.6, scale = 1.2) + theme_minimal(base_size = 14) + labs(x = "分数", y = "分组")山脊图的优势在于各组上下错落,重叠区域互相透视,既保留了分布形状,又能纵向对比多组。它的fill填充也可以映射到密度高度,做成渐变效果,但那属于锦上添花,别一开始就往复杂里搞。
4.2 二维密度图:两个连续变量的联合分布
如果你有两个连续变量,想看它们联合分布,一维密度图就不够用了。ggplot2提供geom_density_2d()和geom_bin2d()。前者画等高线,适合样本量较大且分布有清晰中心的散点;后者做二维分箱热力图,更适合数据点很多且密集区域集中的情况。
set.seed(42) df_2d <- tibble( x = c(rnorm(500, 0, 1.5), rnorm(300, 3, 1.2)), y = c(rnorm(500, 0, 1.5), rnorm(300, 2.5, 1.0)) ) df_2d |> ggplot(aes(x = x, y = y)) + geom_density_2d_filled(alpha = 0.7) + scale_fill_viridis_d() + theme_minimal(base_size = 14) + labs(title = "二维密度等高线填充图")单独的geom_density_2d()只画等高线,不带填充色,视觉上容易发空。我一般直接用_filled版本,再配合半透明,信息量大了很多。注意这类图的颜色离散区间必须连续,不能跳过,scale_fill_viridis_d()里如果有空白区,多半是因为对factor的某个level没分配颜色。
4.3 自定义主题:别再默认灰底白格
ggplot2默认的gray主题用来快速出图很方便,但正式报告和论文里,我通常用theme_minimal()配自定义font和网格线。这里分享一个精简配置:
theme_pub <- function(base_size = 14) { theme_minimal(base_size = base_size) + theme( plot.title = element_text(face = "bold", size = rel(1.4)), plot.subtitle = element_text(color = "grey40", size = rel(1)), axis.title = element_text(face = "bold"), axis.text = element_text(color = "grey30"), panel.grid.minor = element_blank(), panel.grid.major = element_line(color = "grey90", size = 0.4), legend.title = element_text(face = "bold"), legend.position = "top" ) }这个主题我几乎在每个项目里都会先定义好,后续出图直接加theme_pub()。有人喜欢保留细网格线,有人喜欢全去掉,这个看个人审美,但去掉次要网格线基本能让图面干净30%。
5. 常见问题与排查实录
这部分全是我自己在实操里踩过的坑,每条都能对应一个“当时为什么图看着不对劲”的深夜。
5.1 为什么我的密度图y轴值大于1
这不是bug。核密度曲线下方面积恒为1,当数据取值范围很窄时,曲线就会很高,y轴超过1非常正常。要验证你的图是否正确,用ggplot_build()取数据:
data_build <- ggplot_build(p2)$data[[1]] # 检查密度曲线下的面积近似等于 1 # 用梯形法则计算即可,面积约等于 1 就是对的如果你的图y轴都在0.000几,大概率是x轴单位太大,比如以“秒”为单位的时间间隔数据,可以考虑先转换单位到小时或天再做密度图。
5.2 堆叠面积图总有锯齿和缺口
堆叠面积图边缘有锯齿感,通常是数据点数太少造成的。geom_area默认会把每个数据点用直线段连接起来,正常。但如果你把x轴隐式转成了因子,每个x位置不再等距,就会出现奇怪的缺口。解决方案是确认x轴变量是数值或日期型,并且不要用as.factor()处理日期再传入aes。
另一种情况:数据在某时间点存在NA。ggplot2对NA的默认处理是断连,表现在面积图上就是整层塌陷。用tidyr::complete()补齐所有x位置并显式填0或前值,然后再画,就能消除。
5.3 多组密度图重叠到看不清
我前面推荐了半透明和描边方案,但还有一种情况:组数真太多了,曲线像一捆刷子并排。解决办法除了facet_wrap(),还可以试试stat_density(geom = "area", position = "identity")给每个组只留轮廓:
df_dist |> ggplot(aes(x = score, color = group)) + geom_line(stat = "density", adjust = 1.2, size = 0.8) + theme_minimal(base_size = 14)这个方案去掉了填充色,只保留密度曲线,多条线叠加时非常清爽。缺点是丢失了面积感,但走势对比足够清楚,适合组数多的场景。
5.4 中文乱码和字体问题
Windows环境下ggplot2默认字体对中文支持不太好,图里的中文经常变成方框。我一般在使用中文前调用:
# Windows 需要设定中文字体 windowsFonts(SimHei = windowsFont("SimHei")) theme_pub <- function(base_size = 14) { theme_minimal(base_size = base_size) + theme(text = element_text(family = "SimHei")) }macOS上一般用“PingFang SC”或“Heiti SC”,Linux上用“Noto Sans CJK SC”。输出PDF时最好把字体嵌入,否则换个电脑打开中文就乱了。这个坑很容易被人忽略,但出图交出去才发现,返工成本不低。
5.5 导出图片尺寸和清晰度
在RStudio里截屏保存图片是新手最常用的方式,但写报告时分辨率完全不够。我固定用ggsave(),dpi设300,宽高按实际印刷尺寸走。单栏图通常6英寸宽,双栏图12英寸宽:
ggsave("final_figure.png", p2, width = 8, height = 5, dpi = 300) ggsave("final_figure.pdf", p2, width = 8, height = 5, useDingbats = FALSE)PDF导出时,useDingbats = FALSE很重要。不然一些特殊图形符号在PDF阅读器里可能显示异常。
最后再分享一点个人体会:很多人会用“默认配色+默认主题+默认尺寸”直接出图,但真正让图表拉开差距的地方,恰恰是别人不会注意的细节——透明度、描边、网格线、字号、配色一致性。密度分布图、面积图、密度图本身不难画,难的是你能不能让看图的人一眼就抓住分布形态、结构变化、组间差异这三个核心信息。把这个目标记在心里,参数调整和配色选择就有了依据,而不是纯粹在那里调着玩。