☰
ggplot2 stat_density() 核心校验机制深度解析:bounds 边界处理、双向定向与 bw 参数校验
2026/10/5 2:24:03 网站建设 项目流程
  • 数据可视化

【免费下载链接】ggplot2

An implementation of the Grammar of Graphics in R

项目地址:https://gitcode.com/gh_mirrors/gg/ggplot2
点击查看免费下载

导读

stat_density()是 ggplot2 中计算核密度估计(Kernel Density Estimate)的核心统计层,它把原始数据平滑成密度曲线,是直方图在连续分布场景下的重要替代。本文以仓库中 tests/testthat/_snaps/stat-density.md 快照文件为切入点,逐条拆解该文件记录的四类关键行为——越界数据点的剔除与警告、x/y 美学缺失时的报错、点数不足时的降级处理、带宽参数bw的合法性校验——并结合 R/stat-density.R 源码与 tests/testthat/test-stat-density.R 测试用例,讲清每条校验背后的实现原理与设计动机。读完本文,你将理解stat_density()与geom_density()的完整参数体系、边界校正(boundary correction)的反射算法,以及 ggplot2 如何用快照测试锁定这些行为。

一、快照文件是什么:先读懂_snaps/stat-density.md的定位

在进入源码之前,需要先解释这篇关联文档的性质。tests/testthat/_snaps/目录存放的是 testthat 框架的快照测试(snapshot testing)产物:测试运行时,expect_snapshot_warning()与expect_snapshot_error()会把警告信息和错误信息序列化成 Markdown 文本;若实际输出与快照不一致,测试即失败。因此 tests/testthat/_snaps/stat-density.md 中的每一段标题与代码块,都是stat_density()在特定输入下真实产生过的警告/错误原文,是验证该函数行为的"黄金标准"。

该文件共记录了四个测试场景:

快照标题记录的运行时输出对应的源码行为
stat_density handles data outside of boundsSome data points are outside of bounds. Removing them.越界点剔除警告
stat_density works in both directions! stat_density() requires an x or y aesthetic.必需美学缺失报错
compute_density returns useful df and throws warning when <2 valuesGroups with fewer than two data points have been dropped.点数不足降级警告
precompute_bandwidth() errors appropriatelybw must be one of ... not "foobar"/bw must be a finite, positive number, not Inf带宽参数校验报错

这四类行为分别由fit_data_to_bounds()、setup_params()、compute_density()、precompute_bw()四个函数负责,下文逐一展开。

二、bounds越界处理:快照第一条背后的反射算法

快照文件的第一条记录:

# stat_density handles data outside of `bounds` Some data points are outside of `bounds`. Removing them.

2.1 触发场景与参数含义

bounds是stat_density()的参数,表示"已知数据的上下界",默认c(-Inf, Inf)(即无有限边界)。当用户传入有限边界(如bounds = c(1, Inf))时,所有落在边界之外的原始数据点都会被剔除并发出上述警告。对应测试位于 tests/testthat/test-stat-density.R:

test_that("stat_density handles data outside of `bounds`", { cutoff <- mtcars$mpg[1] # Both `x` and `weight` should be filtered out for out of `bounds` points expect_snapshot_warning( data_actual <- get_layer_data( ggplot(mtcars, aes(mpg, weight = cyl)) + stat_density(bounds = c(cutoff, Inf)) ) ) ... })

注意测试注释强调:越界时不仅x被过滤,权重weight也会被同步过滤,否则权重与数据点错位会导致密度估计失真。

2.2 源码实现:fit_data_to_bounds()

剔除逻辑在 R/stat-density.R 的fit_data_to_bounds()中:

fit_data_to_bounds <- function(bounds, x, w) { is_inside_bounds <- (bounds[1] <= x) & (x <= bounds[2]) w_sum <- 1 if (!all(is_inside_bounds)) { cli::cli_warn("Some data points are outside of `bounds`. Removing them.") x <- x[is_inside_bounds] w <- w[is_inside_bounds] w_sum <- sum(w) if (w_sum > 0) { w <- w / w_sum } } return(list(x = x, w = w, w_sum = w_sum)) }

实现要点:

  • 边界判定是闭区间bounds[1] <= x <= bounds[2],边界上的点不会被剔除;
  • 剔除后权重重新归一化(w <- w / w_sum),保证概率质量之和仍为 1;
  • 若剔除后权重和为零,则不做归一化,避免除零错误。

2.3 边界效应的校正:reflect_density()反射算法

剔除越界点只是第一步。bounds更重要的用途是修正核密度估计的边界效应(boundary effect):当数据真实存在下限(如克拉数carat >= 1)时,默认的stats::density()会把概率质量泄漏到边界之外。为此 R/stat-density.R 实现了reflect_density()——将边界外的"尾部"沿最近的边界反射回界内,再叠加到原密度上:

reflect_density <- function(dens, bounds, from, to) { if (all(is.infinite(bounds))) { return(dens) } f_dens <- stats::approxfun( x = dens$x, y = dens$y, method = "linear", yleft = 0, yright = 0 ) left <- max(from, bounds[1]) right <- min(to, bounds[2]) out_x <- seq(from = left, to = right, length.out = length(dens$x)) left_reflection <- f_dens(bounds[1] + (bounds[1] - out_x)) right_reflection <- f_dens(bounds[2] + (bounds[2] - out_x)) out_y <- f_dens(out_x) + left_reflection + right_reflection list(x = out_x, y = out_y) }

其核心思想是:对界内每个点out_x,把bounds[1] + (bounds[1] - out_x)(即关于左边界的镜像点)和bounds[2] + (bounds[2] - out_x)(关于右边界的镜像点)处的密度值累加进来,从而把原本泄漏到界外的概率"折返"进界内,使界内总概率更接近 1。注释还指出:为了让反射前后的密度保持连续,无界估计时会先把估计范围向两侧各拓宽一个区间宽度(R/stat-density.R,关联 issue #5641),并把采样点数按有限边界数量倍增。

测试 tests/testthat/test-stat-density.R 精确验证了这一数学性质:带边界绘图的密度,应等于原始无界密度加上左右两侧反射项之和(围绕无穷远的反射为零):

left_reflection <- orig_density(bounds[1] + (bounds[1] - test_sample)) right_reflection <- orig_density(bounds[2] + (bounds[2] - test_sample)) expect_equal( orig_density(test_sample) + left_reflection + right_reflection, plot_density(test_sample), tolerance = 1e-3 )

expect_bounds(c(-Inf, Inf))、c(mpg_min, Inf)、c(-Inf, mpg_max)、c(mpg_min, mpg_max)四种边界组合全部通过,说明单侧与双侧边界校正均成立。

2.4 实战示例:已知下界的密度估计

man/geom_density.Rd 的官方示例展示了bounds的典型用法——对carat >= 1的钻石数据,对比有/无边界校正的密度曲线:

big_diamonds <- diamonds[diamonds$carat >= 1, ] ggplot(big_diamonds, aes(carat)) + geom_density(color = 'red') + geom_density(bounds = c(1, Inf), color = 'blue')

bounds = c(1, Inf)告知估计器"数据下界为 1",于是蓝色曲线在carat = 1附近不再把概率泄漏到 1 以下,更贴近真实分布。

三、双向定向:x/y美学缺失时的报错链路

快照第二条:

# stat_density works in both directions Problem while computing stat. i Error occurred in the 1st layer. Caused by error in `setup_params()`: ! `stat_density()` requires an x or y aesthetic.

3.1 触发场景

stat_density()的必需美学是"x|y"(见 R/stat-density.R 的required_aes = "x|y"),表示x 与 y 至少提供一个——因为密度曲线既可以沿 x 轴方向绘制,也可以翻转 90° 沿 y 轴方向绘制。当调用ggplot(mpg) + stat_density()(既无x也无y)时,就会抛出上述错误。对应测试在 tests/testthat/test-stat-density.R:

test_that("stat_density works in both directions", { p <- ggplot(mpg, aes(hwy)) + stat_density() x <- get_layer_data(p) expect_false(x$flipped_aes[1]) # x 方向:不翻转 p <- ggplot(mpg, aes(y = hwy)) + stat_density() y <- get_layer_data(p) expect_true(y$flipped_aes[1]) # y 方向:翻转 ... p <- ggplot(mpg) + stat_density() expect_snapshot_error(ggplot_build(p)) })

3.2 源码实现:setup_params()与has_flipped_aes()

报错发生在 R/stat-density.R 的setup_params():

setup_params = function(self, data, params) { params$flipped_aes <- has_flipped_aes( data, params, main_is_orthogonal = FALSE, main_is_continuous = TRUE ) has_x <- !(is.null(data$x) && is.null(params$x)) has_y <- !(is.null(data$y) && is.null(params$y)) if (!has_x && !has_y) { cli::cli_abort("{.fn {snake_class(self)}} requires an {.field x} or {.field y} aesthetic.") } params }

这里has_flipped_aes()(定义于 R/utilities.R)是 ggplot2 的方向自动判定机制:它依次检查数据中是否已编码flipped_aes、params$orientation是否显式指定、x/y 中是否只有一个存在(xor判定)、x/y 是否一个离散一个连续等,最终返回该层应沿 x 轴(FALSE)还是沿 y 轴(TRUE)计算。main_is_continuous = TRUE表示主方向必须是连续轴——密度估计本就要求连续变量。

setup_params的flipped_aes结果随后被传入compute_group(),通过flip_data()/flipped_names()(R/utilities.R)把x与y的美学名互换,从而实现同一套算法在横竖两个方向复用:flip_data(data, flip)在flip = TRUE时调用switch_orientation(names(data))把x/xmin/xmax与y/ymin/ymax系列列名互换,估计完密度后再翻回。测试正是通过expect_identical(x, flip_data(y, TRUE)[, names(x)])断言两种方向的输出互为镜像。

3.3 显式指定方向

如果自动判定失效(如数据本身含混),可用orientation参数显式指定(man/geom_density.Rd 的 Orientation 一节):

stat_density(orientation = "x") # 沿 x 轴计算 stat_density(orientation = "y") # 沿 y 轴计算

orientation属于extra_params(R/stat-density.R),默认NA即自动判定。

四、点数不足的降级处理:快照第三条的"有用数据框"

快照第三条:

# compute_density returns useful df and throws warning when <2 values Groups with fewer than two data points have been dropped.

4.1 触发场景与测试

当某个分组(group)经过bounds过滤或本身就只有 1 个数据点时,核密度估计无法进行。测试 tests/testthat/test-stat-density.R 用单点数据触发:

test_that("compute_density returns useful df and throws warning when <2 values", { expect_snapshot_warning(dens <- compute_density(1, NULL, from = 0, to = 0)) expect_equal(nrow(dens), 1) expect_named(dens, c("x", "density", "scaled", "ndensity", "count", "wdensity", "n")) expect_type(dens$x, "double") })

注意测试标题强调"returns useful df"——虽然数据被丢弃,但函数仍返回一个结构完整、可用于后续绘图管线的单行数据框,而不是返回NULL或直接中断,从而保证整个ggplot_build()流水线不会因个别空分组而崩溃。

4.2 源码实现:compute_density()的分支

实现位于 R/stat-density.R:

# if less than 2 points return data frame of NAs and a warning if (nx < 2) { cli::cli_warn("Groups with fewer than two data points have been dropped.") return(data_frame0( x = NA_real_, density = NA_real_, scaled = NA_real_, ndensity = NA_real_, count = NA_real_, wdensity = NA_real_, n = NA_integer_, .size = 1 )) }

关键点:

  • 返回行数为 1,包含全部七个计算变量的 NA 占位(x、density、scaled、ndensity、count、wdensity、n),列名与正常输出完全一致;
  • 这正是测试断言expect_named(dens, c(...))与expect_type(dens$x, "double")的原因——下游代码按固定列名取数不会出错;
  • 阈值是"少于两个点"(nx < 2),因为单点无法定义带宽、无法计算核密度。

4.3 相关边界情况:零方差数据

顺带一提,测试 tests/testthat/test-stat-density.R 还覆盖了零方差场景:compute_density(rep(0, 10), NULL, from = 0.5, to = 0.5)应正常返回且n列长度为 512(估计点数)。这说明即使所有 x 值相同,只要点数足够,函数也不会报错——这得益于stats::density()自身的健壮性。

五、带宽参数bw的校验:快照第四条的两级报错

快照第四条记录了precompute_bw()的两条错误信息(用---分隔表示两个独立的expect_snapshot_error()):

# precompute_bandwidth() errors appropriately `bw` must be one of "nrd0", "nrd", "ucv", "bcv", "sj", "sj-ste", or "sj-dpi", not "foobar". --- `bw` must be a finite, positive number, not `Inf`.

5.1 触发场景

对应测试 tests/testthat/test-stat-density.R:

test_that("precompute_bandwidth() errors appropriately", { expect_silent(precompute_bw(1:10)) expect_equal(precompute_bw(1:10, 5), 5) expect_snapshot_error(precompute_bw(1:10, bw = "foobar")) # 非法字符串 expect_snapshot_error(precompute_bw(1:10, bw = Inf)) # 非法数值 })

这组测试说明bw校验分两条防线:字符串必须是合法带宽规则名,数值必须是有限正数。

5.2 源码实现:precompute_bw()

实现位于 R/stat-density.R:

precompute_bw <- function(x, bw = "nrd0") { bw <- bw[1] if (is.character(bw)) { bw <- to_lower_ascii(bw) bw <- arg_match0(bw, c("nrd0", "nrd", "ucv", "bcv", "sj", "sj-ste", "sj-dpi")) bw <- switch( to_lower_ascii(bw), nrd0 = stats::bw.nrd0(x), nrd = stats::bw.nrd(x), ucv = stats::bw.ucv(x), bcv = stats::bw.bcv(x), sj = , `sj-ste` = stats::bw.SJ(x, method = "ste"), `sj-dpi` = stats::bw.SJ(x, method = "dpi") ) } if (!is.numeric(bw) || bw <= 0 || !is.finite(bw)) { cli::cli_abort( "{.arg bw} must be a finite, positive number, not {obj_type_friendly(bw)}." ) } bw }

实现要点:

  • arg_match0()是 rlang 风格的精确匹配函数,非法字符串会生成快照中第一条错误——完整列出七个合法值:"nrd0"、"nrd"、"ucv"、"bcv"、"sj"、"sj-ste"、"sj-dpi",对应stats包的bw.nrd0()、bw.nrd()、bw.ucv()、bw.bcv()、bw.SJ()系列带宽选择器;
  • 传入字符串时先统一转小写(to_lower_ascii),因此大小写不敏感;
  • 字符串规则在进入stats::density()之前就被解析成数值带宽——这正是函数名为precompute_bw的原因,也避免了重复调用带宽选择器;
  • 若bw是数值,则跳过字符串分支直接进入数值校验:必须是有限正数,Inf、负数、零、NA都会被第二条错误拦截;
  • bw <- bw[1]只取第一个元素,防止向量输入。

5.3bw与adjust的分工

根据 man/geom_density.Rd 的参数说明:bw若为数值,表示平滑核的标准差;若为字符,则是带宽选择规则。而adjust是带宽的乘性微调系数——例如adjust = 1/2表示用默认带宽的一半(更尖的曲线),adjust = 5表示五倍带宽(更平滑的曲线),见 R/geom-density.R 的示例:

ggplot(diamonds, aes(carat)) + geom_density(adjust = 1/5) ggplot(diamonds, aes(carat)) + geom_density(adjust = 5)

官方文档还特别提醒:自动带宽计算不考虑权重,这在加权密度估计时需自行权衡。

六、stat_density()完整参数体系与计算变量

把快照涉及的校验放回完整上下文中,stat_density()的全部参数如下(源自 man/geom_density.Rd 的 usage 与 R/stat-density.R 的构造器定义):

参数默认值作用与取值
bw"nrd0"带宽:数值(核标准差)或七个合法字符规则之一
adjust1带宽乘性调整,如1/2、5
kernel"gaussian"核函数,见stats::density()的可用核列表
n512估计点个数,建议为 2 的幂
trimFALSETRUE时每组密度仅在其自身数据范围内计算(各组 x 网格不对齐,无法堆叠)
boundsc(-Inf, Inf)已知数据上下界;有限边界触发反射校正,越界点被剔除并警告
orientationNA显式指定"x"/"y"方向,默认自动判定
na.rmFALSE缺失值是否静默移除
geom"area"默认几何层为面积图
position"stack"默认位置调整为堆叠

compute_density()输出的计算变量(可经after_stat()延迟求值访问,见 man/geom_density.Rd 的 Computed variables 一节):

变量定义典型用途
density密度估计值默认 y 轴值
countdensity × 点数堆叠密度图(after_stat(count))
wdensitydensity × 权重和(无权重时等同count)加权堆叠
scaled密度归一化到最大值为 1相对比较
ndensityscaled的别名,对齐stat_bin()语法兼容写法
n点数分组大小

一个典型用例(官方示例,见 man/geom_density.Rd):默认geom_density(position = "stack")会丢失各组边缘密度,改用after_stat(count)后各组面积正比于样本量,堆叠结果更合理:

ggplot(diamonds, aes(carat, after_stat(count), fill = cut)) + geom_density(position = "stack")

七、从快照到源码:ggplot2 的测试驱动开发范式

纵观这四条快照,可以归纳出 ggplot2 对stat_density()的质量保障体系(tests/testthat/test-stat-density.R):

  1. 正确性测试:与stats::density()直接对比(stat_density actually computes density),验证统计层不是黑盒;
  2. 数学性质测试:stat_density uses bounds用反射公式精确断言边界校正结果;
  3. 健壮性测试:零方差数据、单点数据、非法bw、缺失方向映射等异常输入全部有明确、稳定的行为;
  4. 快照锁定:所有警告与错误原文存入_snaps/stat-density.md,任何微小的提示语改动都会触发回归提醒。

stat_density的 ggproto 定义(R/stat-density.R)清晰地划分了职责:required_aes = "x|y"声明美学依赖、setup_params()负责方向判定与早期报错、compute_group()负责逐组计算并调用compute_density()、fit_data_to_bounds()/reflect_density()/precompute_bw()三个辅助函数分别承担越界过滤、边界校正与带宽校验。理解这条调用链,就掌握了stat_density()从原始数据到密度曲线的完整旅程。

结语

stat_density()的四条快照看似只是几行报错文本,实则是该统计层七大参数、六个计算变量与三个核心算法(反射边界校正、双向翻转、带宽预计算)的行为缩影。无论你是想用bounds修正截断数据(如carat >= 1)的密度泄漏、用after_stat(count)构建正确的堆叠密度图,还是调试"requires an x or y aesthetic"与带宽校验报错,都可以回到 R/stat-density.R 与 tests/testthat/test-stat-density.R 找到精确的答案——这也是把快照文件当"行为文档"来读的价值所在。

  • 数据可视化

【免费下载链接】ggplot2

An implementation of the Grammar of Graphics in R

项目地址:https://gitcode.com/gh_mirrors/gg/ggplot2
点击查看免费下载

相关推荐

上一篇:ESP-IoT-Solution 摄像头应用实战指南:从 esp32-camera 驱动到拍摄、推流与录制
下一篇:Meteor 客户端专用文件变更与服务器重启隔离:client-refresh 测试包源码级剖析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询