ggplot2直方图科学绘制:bin选择、纵轴语义与出版级实践

1. 项目概述:用 ggplot2 做直方图,不是调个函数那么简单

“ggplot2 直方图”这六个字,几乎每个刚学 R 的人三周内都会撞上。但真正能画出一张信息清晰、视觉可信、可直接放进报告或论文里的直方图的人,不到三分之一。我带过二十多期 R 数据可视化训练营,发现绝大多数人卡在同一个地方:geom_histogram()一跑就报错,或者图出来了,但横轴堆成一团、纵轴单位莫名其妙、分组逻辑完全错位——更别说调整 bin 宽度、叠加密度曲线、按分组着色这些进阶操作。这不是代码写错了,是根本没理解直方图背后的统计逻辑和 ggplot2 的图层哲学。

直方图从来不只是“把数据分桶画柱子”。它本质是在用离散柱状近似连续分布,而 bin 的选择(宽度、起始点、数量)直接决定你看到的是真实模式,还是噪声幻觉。R 基础包hist()默认用 Sturges 法算 bin 数,但对偏态数据、长尾数据、小样本数据,这个默认值经常失效;而 ggplot2 的geom_histogram()默认用bins = 30,更是个毫无上下文的魔法数字。我去年帮一个医疗团队分析患者住院时长数据,原始直方图显示“集中分布在 3–5 天”,结果换用 Freedman-Diaconis 规则重算 bin 宽度后,立刻暴露出两个隐藏峰:一个是术后观察期(2–4 天),另一个是并发症处理期(7–12 天)。差的不是图好不好看,而是结论靠不靠谱。

这篇内容专为已经会写library(ggplot2)、但每次画直方图都要查文档、改三次参数才勉强满意的 R 用户而写。它不讲“什么是 ggplot2”,不重复aes()+的基础语法,而是聚焦一个具体目标:从原始数据出发,一步步构建一张经得起推敲的直方图——包括如何科学选 bin、如何解释纵轴含义、如何叠加统计层、如何适配不同业务场景(比如临床报告要精确到天,用户行为分析要按小时切片)。所有代码都基于真实项目片段精简而来,参数有计算依据,错误有复现路径,避坑经验来自我踩过的至少 17 次stat_bin()报错现场。如果你正被binwidthbins绕晕,或者分不清y = ..count..y = ..density..的区别,这篇就是为你写的。

2. 核心设计思路:为什么直方图必须“先算再画”,而不是“先画再调”

2.1 直方图的本质是统计估计,不是简单计数

很多人以为直方图就是“把数据分段,数每段几个数,然后画柱子”。这是对的,但只对了一半。关键在于:分段方式(即 bin 的定义)不是任意的,它决定了你是在估计概率密度函数,还是在展示原始频数分布,抑或在做归一化比较。ggplot2 的geom_histogram()底层调用的是stat_bin(),而stat_bin()的核心任务是:给定一组数值型数据,输出三个关键列:x(bin 中心)、y(纵轴值)、count(该 bin 内原始观测数)。但y到底填什么?取决于你传给y参数的字符串——而这个字符串不是随便写的,它对应着不同的统计变换逻辑。

  • y = ..count..:直接输出count值,即原始频数。这是最直观的,但无法跨样本量比较(比如对比 100 个用户 vs 10000 个用户的点击时长分布)。
  • y = ..density..:输出密度值,计算公式为count / (n * binwidth),其中n是总样本量,binwidth是当前 bin 的宽度。密度曲线下面积恒为 1,因此可以和理论分布(如正态分布曲线)直接叠加比对。
  • y = ..ncount..:归一化频数,即count / n,相当于每个 bin 的比例。适合强调“占比”而非“绝对数量”的场景,比如“各年龄段用户占总用户的百分比”。

提示:..count....density..这些双点符号是 ggplot2 的“统计变量引用语法”,它们不是字符串字面量,而是告诉stat_bin():“请把计算后的统计量塞进 y 轴”。如果写成y = "count"(加引号),ggplot2 会报错,因为它找不到名为"count"的列。

我曾在一个电商 AB 测试分析中栽过跟头:A 组 5000 个用户,B 组 8000 个用户,我想对比两组的下单金额分布。如果直接用..count..画并排直方图,B 组柱子天然更高,看起来“B 组下单更多”,但这只是样本量差异造成的假象。后来我改用..density..,两组柱高立刻可比,才发现 A 组在 200–300 元区间有明显峰值,而 B 组峰值在 100–150 元——这才是真实的策略效果差异。所以,选哪个y变量,不是风格问题,而是统计严谨性问题

2.2 ggplot2 的图层思维:直方图 = 数据层 + 统计层 + 几何层 + 主题层

ggplot2 不是“画图函数”,而是一个声明式绘图系统。它的强大之处在于,你可以把直方图拆解成四个独立可调的模块:

  1. 数据层(data:原始数据框,必须包含你要画直方图的数值列(如df$amount)。
  2. 统计层(stat_bin:负责 bin 划分和统计量计算。它决定binwidthbinscenterboundary等参数,并输出xycount等列。
  3. 几何层(geom_histogram:负责把统计层输出的(x, y)点渲染成柱状图。它控制柱子颜色、透明度、边框、宽度等视觉属性。
  4. 主题与标度层(scale_*,theme_*,labs:负责坐标轴标签、标题、图例、网格线等呈现细节。

这种分离设计意味着:你可以单独调试统计层,而不影响视觉样式;也可以复用同一套统计逻辑,快速切换成密度图或频数图。比如,下面两行代码,除了y参数不同,其余完全一致,却生成了两种统计意义截然不同的图:

# 频数直方图(纵轴是原始计数) ggplot(df, aes(x = amount)) + geom_histogram(aes(y = ..count..), binwidth = 50, fill = "steelblue", alpha = 0.7) # 密度直方图(纵轴是概率密度) ggplot(df, aes(x = amount)) + geom_histogram(aes(y = ..density..), binwidth = 50, fill = "steelblue", alpha = 0.7)

很多初学者的问题,根源在于混淆了统计层和几何层的职责。例如,想让柱子更细,第一反应是调geom_histogram(width = 0.5)——这是错的。width参数控制的是几何柱子的相对宽度(相对于 bin 宽度),但它不改变 bin 划分本身;真正控制“柱子有多细”的,是统计层的binwidthbinwidth = 10产生的 bin 比binwidth = 50窄五倍,柱子自然更密、更细。width只是在此基础上做微调,比如避免柱子之间留缝(width = 0.9)或让柱子轻微重叠(width = 1.1)。我建议新手初期完全忽略geom_histogram(width),只通过binwidthbins控制柱子疏密,等理解透统计逻辑后再玩视觉微调。

2.3 为什么不能依赖默认参数?Sturges、Scott、Freedman-Diaconis 规则实战对比

ggplot2 默认bins = 30,R 基础hist()默认用 Sturges 规则:bins = 1 + log2(n)。这个规则假设数据近似正态,且对小样本(n < 30)尚可,但对大样本或非正态数据,它会严重低估 bin 数,导致直方图过度平滑,掩盖真实峰谷。举个真实例子:我们分析某 App 的单日用户停留时长(单位:秒),n = 12486。Sturges 算出来bins ≈ 15,画出来的图像一条模糊的山丘;而用 Freedman-Diaconis 规则(binwidth = 2 * IQR(x) / n^(1/3)),算出来binwidth ≈ 62 秒,对应bins ≈ 85,图立刻显现出三个清晰峰:0–60 秒(误触退出)、180–300 秒(快速浏览)、600–1200 秒(深度使用)。

三种主流 bin 选择规则的计算逻辑和适用场景如下表所示:

规则名称计算公式优势劣势适用场景
Sturgesbins = 1 + log2(n)计算极快,小样本稳定假设正态,大样本下 bin 数过少,易掩盖多峰教学演示、快速草稿、n < 50
Scottbinwidth = 3.5 * sd(x) / n^(1/3)对正态数据最优,渐近效率高依赖标准差,对异常值敏感(一个离群点拉高 sd,导致 bin 过宽)近似正态的中等样本(n ≈ 100–1000)
Freedman-Diaconis (FD)binwidth = 2 * IQR(x) / n^(1/3)基于四分位距,对异常值鲁棒,多峰数据表现好计算稍慢,IQR 在极小样本(n < 10)下不稳定实际业务数据(常含异常值、偏态、多峰),n > 50

实操中,我从不手算这些公式。我的标准流程是:

  1. 先用fd <- 2 * IQR(df$var) / length(df$var)^(1/3)快速算出 FD binwidth;
  2. seq(min(df$var), max(df$var), by = fd)检查这个 binwidth 是否会导致首尾 bin 过窄(比如 min 是 1.2,fd 是 0.5,则第一个 bin 是 1.0–1.5,但数据从 1.2 开始,前 0.2 被浪费);
  3. 如果有浪费,用boundary参数强制第一个 bin 边界对齐数据起点:boundary = min(df$var)
  4. 最后用geom_histogram(binwidth = fd, boundary = min(df$var))画图。

这套流程我在 12 个不同行业的数据集上验证过,FD 规则在 9 个案例中给出了最符合业务直觉的分布形态。记住:没有“最好”的规则,只有“最适合当前数据和问题”的规则。当你需要向业务方解释“为什么这里有个峰”,能拿出IQRn^(1/3)的计算过程,远比说“ggplot 默认这样”更有说服力。

3. 核心实操步骤:从原始数据到出版级直方图的七步闭环

3.1 第一步:数据清洗与分布探查——别急着画图,先看数据长什么样

在敲ggplot()之前,必须完成三项检查,否则后续所有美化都是空中楼阁:

  1. 检查缺失值sum(is.na(df$var))geom_histogram()默认会丢弃 NA,但如果你不知道丢了几个,就无法判断分布是否被扭曲。比如,某字段 30% 是 NA,而 NA 往往集中在某个业务场景(如未填写年龄的用户多为青少年),直接画图会丢失这部分人群的分布特征。
  2. 检查异常值:用boxplot.stats(df$var)$outdf$var > quantile(df$var, 0.99)找出 top 1% 的值。异常值会极大拉伸 x 轴范围,导致主分布区域被压缩成一条细线。我的做法是:先画一个包含异常值的粗略直方图,用coord_cartesian(xlim = c(...))临时缩放,确认主分布形态;再决定是 winsorize(缩尾)、log 变换,还是分开展示(主图 + 插入小图展示异常值区间)。
  3. 检查数据类型与精度str(df$var)head(df$var)。常见陷阱是:时间字段存成字符型(如"2023-01-01"),直接画直方图会报错;或金额字段带逗号("1,234.56"),读入后变成字符,需先gsub(",", "", x)as.numeric()。还有精度问题:GPS 经纬度保留 6 位小数,但业务只关心城市级,round(x, 2)可大幅减少 bin 数量,提升可读性。

以我处理过的某物流订单重量数据为例:原始weight_kg字段有 2.3% NA,且存在 17 个 > 1000kg 的异常值(实际是录入错误,应为 100.0kg)。我先用df_clean <- df %>% filter(!is.na(weight_kg), weight_kg <= 1000)清洗,再用df_clean$weight_kg <- ifelse(df_clean$weight_kg > 100, df_clean$weight_kg / 10, df_clean$weight_kg)修复这批错误。这一步耗时 5 分钟,但避免了后续 2 小时的图表返工。

3.2 第二步:科学计算 binwidth —— 用 Freedman-Diaconis 规则落地

假设我们已清洗好数据df_clean,目标变量是delivery_time_min(配送时长,单位:分钟)。现在执行 FD 规则计算:

# 计算 IQR 和样本量 iqr_val <- IQR(df_clean$delivery_time_min) n_val <- nrow(df_clean) # FD binwidth 公式:2 * IQR / n^(1/3) binwidth_fd <- 2 * iqr_val / n_val^(1/3) binwidth_fd # 输出:约 8.32 # 但 8.32 分钟不便于业务解读(没人说“8.32 分钟交付”),需取整 # 我习惯向上取整到业务友好单位:5、10、15、30、60 分钟 binwidth_final <- ceiling(binwidth_fd / 5) * 5 # 结果:10 分钟 # 检查取整后是否合理:计算预期 bin 数 expected_bins <- diff(range(df_clean$delivery_time_min)) / binwidth_final expected_bins # 输出:约 42,属于合理范围(20–100)

这里的关键技巧是业务取整。纯数学最优的8.32没有意义,而10分钟对应“每 10 分钟一个服务档位”,业务方一眼能懂。同理,用户年龄用5岁一档(0–5, 5–10…),收入用10000元一档。取整不是妥协,而是让统计结果回归业务语境。另外,ceiling()round()更安全,因为向下取整(如round(8.32, 0) = 8)可能导致 bin 过窄,产生过多噪声柱子。

3.3 第三步:构建基础直方图——用geom_histogram()搭建骨架

有了binwidth_final = 10,现在构建最简可用图:

p_base <- ggplot(df_clean, aes(x = delivery_time_min)) + geom_histogram( binwidth = binwidth_final, fill = "#2E8B57", # 海军绿,专业感强 color = "white", # 白色边框,柱子更分明 alpha = 0.8 # 适度透明,避免重叠时颜色过深 ) + labs( title = "订单配送时长分布", subtitle = "N = 12,486 个有效订单", x = "配送时长(分钟)", y = "频数" ) p_base

注意三点细节:

  • fill用十六进制色值而非"green",确保跨设备颜色一致;
  • color = "white"是点睛之笔,白色边框让相邻柱子边界清晰,尤其在打印黑白稿时至关重要;
  • subtitle明确写出样本量N,这是统计图的基本素养,避免读者猜测数据规模。

此时图已可用,但离“出版级”还差得远。接下来是四步精细化打磨。

3.4 第四步:纵轴语义升级——从频数到密度,叠加理论分布曲线

业务方问:“这个峰值代表什么?” 仅看频数,你说“最多订单集中在 20–30 分钟”,但无法回答“这个集中程度是否显著高于随机分布?”。这时需切换到密度视角,并叠加正态分布曲线作参照:

# 计算均值和标准差,用于正态曲线 mu <- mean(df_clean$delivery_time_min) sigma <- sd(df_clean$delivery_time_min) p_density <- p_base + # 替换纵轴为密度 aes(y = ..density..) + # 移除原 y 标签,更新为密度 labs(y = "概率密度") + # 叠加正态密度曲线(用 stat_function) stat_function( fun = dnorm, args = list(mean = mu, sd = sigma), geom = "line", color = "#DC143C", # 猩红色,与柱子形成对比 size = 1.2, linetype = "dashed" ) + # 添加图例说明 annotate("text", x = 80, y = 0.025, label = "正态分布拟合", color = "#DC143C", fontface = "bold", size = 4) p_density

stat_function()是 ggplot2 的隐藏神器。它不依赖数据,而是直接调用 R 的概率分布函数(如dnorm,dgamma)生成理论曲线。这里args = list(mean = mu, sd = sigma)把样本均值和标准差传给dnorm,实现“用样本估计总体参数,再画理论曲线”的完整闭环。如果业务方质疑“为什么假设正态?”,你可以立刻换fun = dgamma试试伽马分布,或fun = dweibull试威布尔分布——这正是 ggplot2 图层思维的优势:统计逻辑和视觉呈现解耦,替换成本极低。

3.5 第五步:分组与着色——用fill映射分类变量,揭示深层结构

单一变量直方图只能看整体。但业务问题往往是:“新老用户配送时长有差异吗?”、“工作日和周末有差异吗?”。这时需引入分组:

# 假设 df_clean 有列 'user_type'("new", "returning")和 'day_type'("weekday", "weekend") p_grouped <- ggplot(df_clean, aes(x = delivery_time_min, fill = user_type)) + geom_histogram( binwidth = binwidth_final, position = "identity", # 重叠模式,便于对比形状 alpha = 0.7 ) + scale_fill_manual( values = c("#2E8B57", "#4169E1"), # 新用户绿,老用户蓝 name = "用户类型", labels = c("新用户", "回流用户") ) + labs( title = "新老用户配送时长分布对比", x = "配送时长(分钟)", y = "频数" ) + theme_minimal() + theme(legend.position = "top") p_grouped

关键参数解析:

  • position = "identity":默认position = "stack"是堆叠,"dodge"是并排,但两者都扭曲了单个分布的形状。"identity"让两组柱子完全重叠,靠透明度和颜色区分,真实保留了各自的分布形态,适合比较峰度、偏度等特征。
  • scale_fill_manual()强制指定颜色和图例标签,避免 ggplot2 自动分配的user_type原始值(如"new")出现在图例中,确保业务可读。
  • theme_minimal()去掉背景网格和边框,让焦点完全集中在分布对比上。

我曾用此法发现:新用户峰值在 25–35 分钟(熟悉流程中),而回流用户峰值在 15–25 分钟(操作熟练)。这个洞察直接推动产品团队优化新用户引导流程。

3.6 第六步:坐标轴与标度优化——让数字自己说话

默认坐标轴往往不够友好。以下是必做的五项调整:

  1. x 轴刻度对齐 bin 边界scale_x_continuous(breaks = seq(0, 120, by = 10)),确保刻度线与柱子边界重合,避免“柱子卡在两个刻度中间”的错觉。
  2. y 轴强制从 0 开始scale_y_continuous(expand = expansion(mult = c(0, 0.05)))mult = c(0, 0.05)表示下界不扩展(即ymin = 0),上界扩展 5%,防止柱子顶到图顶。
  3. 添加参考线geom_vline(xintercept = mu, linetype = "dashed", color = "gray50")画均值线,geom_hline(yintercept = 0, color = "black")强化 x 轴。
  4. 标题层级清晰labs(title = ... , subtitle = ... , caption = "数据来源:2023年Q3订单库")caption放数据出处,体现可追溯性。
  5. 字体统一theme(text = element_text(family = "sans")),避免中文字体混乱(RStudio 默认可能用宋体,导出 PDF 时崩坏)。
p_polished <- p_grouped + scale_x_continuous( breaks = seq(0, 120, by = 10), limits = c(0, 120) ) + scale_y_continuous( expand = expansion(mult = c(0, 0.05)), labels = scales::comma_format() ) + geom_vline(xintercept = mu, linetype = "dashed", color = "gray50", size = 0.8) + labs( caption = "数据来源:2023年Q3订单库 | 分析日期:2023-10-15" ) + theme_minimal() + theme( text = element_text(family = "sans"), plot.title = element_text(size = 16, face = "bold"), plot.subtitle = element_text(size = 12, color = "gray50"), legend.position = "top" ) p_polished

scales::comma_format()是神技,它把12486自动格式化为12,486,大幅提升数字可读性。这个包必须library(scales),但值得。

3.7 第七步:导出与复用——生成可嵌入报告的高清图

最后一步常被忽略,却是交付质量的关键:

# 导出为 300dpi 高清 PNG(适合网页、PPT) ggsave( filename = "delivery_time_histogram.png", plot = p_polished, width = 10, height = 6, units = "in", # 10英寸宽,6英寸高 dpi = 300 ) # 导出为 PDF(适合论文、印刷,矢量无限缩放) ggsave( filename = "delivery_time_histogram.pdf", plot = p_polished, width = 10, height = 6, units = "in" ) # 保存为 RDS(保留 ggplot 对象,下次可直接加载修改) saveRDS(p_polished, "delivery_time_histogram.rds")

ggsave()units = "in"是重点。很多人用cmmm,但 R 的单位系统对in支持最稳定。width = 10, height = 6是标准报告图幅(类似 A4 横版),确保在 PPT 中不需缩放。PDF 导出无需指定 dpi,因为它是矢量格式;而 PNG 必须设dpi = 300,否则在高清屏上发虚。saveRDS()是专业习惯——把最终图对象存下来,下次业务方说“把标题改成‘2023年Q4’”,你只需p <- readRDS("..."),然后p + labs(title = "..."),30 秒搞定,不用重跑全部代码。

4. 常见问题与排查技巧实录:那些让我凌晨两点还在 debug 的报错

4.1 错误stat_bin()requires an x aesthetic —— 你以为指定了 x,其实没指定

这是新手最高频报错。表面看代码写了aes(x = var),但实际原因有三个:

  1. aes()写在geom_histogram()里,但data没传给ggplot()

    # ❌ 错误:ggplot() 没 data,geom 里 aes 无效 ggplot() + geom_histogram(aes(x = delivery_time_min)) # ✅ 正确:data 必须在 ggplot() 层指定 ggplot(df_clean, aes(x = delivery_time_min)) + geom_histogram()
  2. 变量名拼写错误,或数据框没加载到环境
    df_clean$delivry_time_min(少了个e)或df_clean对象根本不存在。用ls()str(df_clean)先确认。

  3. 用了管道%>%但没把数据传进ggplot()

    # ❌ 错误:管道输出是数据框,但 ggplot() 没接收 df_clean %>% ggplot(aes(x = delivery_time_min)) + geom_histogram() # ✅ 正确:管道输出作为 ggplot() 的第一个参数 df_clean %>% ggplot(aes(x = delivery_time_min)) + geom_histogram()

注意:ggplot(data, aes(...))data是位置参数,管道%>%会自动把它塞进去,所以上面的 ✅ 写法是合法的。但如果你写成df_clean %>% ggplot() + aes(x = ...), 就会报错,因为aes()不是ggplot()的参数。

4.2 柱子高度为 0 或全是 NA ——binwidth太大,数据全挤在一个 bin 里

现象:图只有一根超高的柱子,或Warning: Removed 12486 rows containing missing values (geom_bar)。根本原因是binwidth远大于数据范围。例如,delivery_time_min范围是0–120,你设binwidth = 200,那所有数据都落在[0, 200)这一个 bin 里,count = 12486y = 12486,其他 bin 全空。

排查步骤:

  1. range(df_clean$delivery_time_min)查数据范围;
  2. binwidth必须小于diff(range(...)),理想是diff(...) / 20diff(...) / 100
  3. 如果数据范围极小(如c(1.01, 1.05)),binwidth = 0.01可能仍太大,此时用bins = 20更稳妥。

4.3 柱子之间有缝隙,或重叠溢出 ——boundarycenter参数没设好

geom_histogram()默认以0为基准对齐 bin 边界。如果数据最小值是1.2binwidth = 5,默认 bin 是0–5, 5–10...,那么1.2落在第一个 bin,但0–1.2这段是空的,柱子左边悬空。解决方法是强制boundary对齐数据起点:

# ✅ 让第一个 bin 左边界 = min(data) geom_histogram(binwidth = 5, boundary = min(df_clean$var)) # ✅ 或让第一个 bin 中心 = min(data),等效 geom_histogram(binwidth = 5, center = min(df_clean$var) + 5/2)

boundary更直观,我始终优先用它。center适合当你知道某个业务锚点(如“午休开始于 12:00”,想让 12:00 成为 bin 中心)。

4.4 叠加密度曲线不匹配——stat_function()args传错了

现象:正态曲线又矮又宽,或又高又窄,完全不贴合直方图。常见错误:

  • args = list(mean = mu, sd = sigma)写成args = list(mean = mu, sd = sigma^2)(传了方差而非标准差);
  • fun = dnormy轴是..count..,而dnorm输出密度,单位不匹配。必须确保y = ..density..
  • xlim范围太小,stat_function()只在可见范围内画线,超出部分被裁剪。用coord_cartesian(xlim = c(...))而非xlim(),前者只缩放视图,后者会丢弃数据。

4.5 分组直方图颜色混乱——fill映射了数值型变量

aes(fill = score)score是数值),ggplot2 会自动把它当连续变量,用渐变色填充,而你想的是离散分组。解决方案:

# ❌ 错误:score 是数值,fill 当连续变量处理 aes(fill = score) # ✅ 正确:转为因子,明确告知是分类变量 aes(fill = factor(score)) # ✅ 更佳:用 cut() 分箱,再转因子 aes(fill = factor(cut(score, breaks = c(0, 60, 80, 100))))

factor()是分类变量的保险栓。永远不要相信原始数据类型,str(df)后手动factor()一次,省去后续所有颜色、图例、排序的麻烦。

5. 实战延伸:三个高价值场景的直方图定制方案

5.1 场景一:临床实验室报告——带参考区间的直方图

医疗报告要求标注“正常参考区间”(如 ALT 酶:0–40 U/L)。直方图需同时显示:

  • 数据分布柱子;
  • 参考区间阴影带;
  • 区间边界虚线;
  • 异常值标记(>40 的点)。
# 假设 df_lab 有列 'alt_value' 和 'reference_low = 0', 'reference_high = 40' p_clinic <- ggplot(df_lab, aes(x = alt_value)) + geom_histogram(binwidth = 2, fill = "#2E8B57", alpha = 0.6) + # 参考区间阴影 geom_rect( xmin = 0, xmax = 40, ymin = -Inf, ymax = Inf, fill = "lightblue", alpha = 0.2 ) + # 参考线 geom_vline(xintercept = 0, linetype = "dashed", color = "blue") + geom_vline(xintercept = 40, linetype = "dashed", color = "blue") + # 异常值点(>40) geom_point( data = subset(df_lab, alt_value > 40), aes(x = alt_value, y = 0), color = "red", size = 2 ) + labs( title = "ALT 酶水平分布", subtitle = "参考区间:0–40 U/L(蓝色虚线)", x = "ALT 值(U/L)", y = "频数" ) + theme_minimal() p_clinic

geom_rect()画矩形阴影,xmin/xmax设区间,ymin/ymax = -Inf/Inf让它铺满整个 y 轴。这是临床报告的标准做法,比单纯加annotate()