跳到正文

ggplot2常用几何对象:柱状图、折线图与箱线图

选几何对象(geom)之前先回答一个问题:数据里的一行,对应图上的什么?是一个点、一根柱子,还是一条线的转折点。回答清楚了,geom 基本就定了;回答不清楚,画出来的图通常也不能用。

两个连续变量之间的关系用 geom_point()。以 iris 的花瓣长度和萼片长度为例:

library(ggplot2)
ggplot(iris, aes(x = Sepal.Length, y = Petal.Length, color = Species)) +
geom_point(size = 2, alpha = 0.8)
图形输出:散点图,150 个点分 3 种颜色,右侧有标题为 Species 的图例。
x 轴 Sepal.Length 范围 4.3–7.9,y 轴 Petal.Length 范围 1.0–6.9。
setosa(桃红)聚在左下,花瓣长度全部小于 2;versicolor(青绿)居中;
virginica(蓝)偏右上,花瓣长度都在 4.5 以上。两类之间几乎不重叠。

点一多就会重叠。当 x 是离散变量(比如剂量、处理组),同一水平上的点会叠成一条竖线,看不出分布——这时候换成 geom_jitter(),它等于 geom_point() 加上随机的水平抖动:

ggplot(ToothGrowth, aes(x = factor(dose), y = len)) +
geom_jitter(width = 0.15, alpha = 0.7)
图形输出:3 列散点,每列 20 个点,x 轴为因子 factor(dose)(0.5 / 1 / 2),
y 轴为牙长 len。每列的点被随机向左右抖开约 ±0.15,重叠减少。
0.5 剂量组的点分布在 4.2–21.5 之间,1 组在 13.6–27.3,2 组在 18.5–33.9。

width 控制抖动的幅度,超过 0.4 就可能越过相邻类别,反而误导读者。注意抖动是随机的:每次运行、每台机器上的位置都不一样。写论文时如果图要可复现,先 set.seed(2026) 固定随机数,或者干脆换用箱线图。

size 指定的是点的直径,单位是毫米,默认 1.5。连续变量配对时点数上千,geom_point() 会糊成一块黑斑,点密度信息全部丢失。按数据量分三档处理:

点数 做法
几十到几百 geom_point(alpha = 0.5),让重叠区域显出色深
上千 geom_bin2d(bins = 12),用矩形颜色的深浅表示落点计数
上千且想看形状 geom_point(alpha = 0.05) 叠一层 geom_density_2d(),等密度线勾出轮廓
set.seed(2026)
d <- with(mtcars, data.frame(wt = rep(wt, 40), mpg = rep(mpg, 40) + rnorm(1280, 0, 0.3)))
ggplot(d, aes(wt, mpg)) +
geom_bin2d(bins = 12)

geom_bin2d() 把画布切成 12 × 12 个矩形,颜色越浅表示该格里的观测越多。它给出的信息量比散点图大:既看得出相关趋势,也看得出数据集中在哪一段。代价是颜色深浅需要在图例里配一个连续刻度,读者要对照着读。

geom_smooth() 默认按 x 排序后拟合一条平滑曲线,适合展示趋势而不是精确数值。method 有三个常用取值:

  • "loess":默认值,局部加权回归。点数少于 1000 时是首选,span 参数(0.2–1)控制平滑程度,越大越平。
  • "lm":最小二乘直线,配合 formula = y ~ x 使用,可以写 y ~ poly(x, 2) 拟合二次曲线。
  • "gam":广义可加模型,点数超过 1000 时用它替代 loess。

一个常见的误用是拿 loess 曲线去外推。平滑曲线在数据范围的两端没有数据支撑,置信带会张得很开,那一段的走势基本是模型编出来的。如果审稿人问「曲线右端为什么翘起来」,那是拟合方式的问题,不是数据的问题。

这是概念上最容易混的一对,差别在于要不要做统计变换(stat):

  • geom_bar() 默认 stat = "count",它先数出每个 x 类别有多少行,再拿这个计数当柱高。所以只需要给 x
  • geom_col()stat = "identity",直接把 y 的值当柱高。所以必须给 x 和 y
# 一行数据是一辆车,柱子高度是车的数量
ggplot(mtcars, aes(x = factor(cyl))) +
geom_bar()
图形输出:3 根等宽的灰色柱子,x 轴为因子 factor(cyl)(4 / 6 / 8),
y 轴标题 count,柱高分别为 11、7、14。8 缸车型最多,6 缸最少。

柱高不是凭空来的,就是分组计数:

table(mtcars$cyl)
4 6 8
11 7 14

如果手里已经算好了均值或总数,用 geom_col()。硬撑着给 geom_bar()stat = "identity" 也能跑,但读代码的人要多想一步。反过来,给 geom_col() 的 y 传原始观测值(一个 x 类别下有多行),它会把柱子叠起来,通常不是你想要的。

柱状图只适合比较一个数值指标,不要用它表现两个连续变量的关系,那是散点图的活。

ToothGrowth 记录的是维生素 C 对豚鼠牙生长的影响:len 是牙长,supp 是给药方式(OJ 橙汁 / VC 抗坏血酸),dose 是每日剂量。先按两个分组变量算平均牙长:

tg <- aggregate(len ~ supp + dose, data = ToothGrowth, FUN = mean)
tg
supp dose len
1 OJ 0.5 13.23
2 VC 0.5 7.98
3 OJ 1.0 22.70
4 VC 1.0 16.77
5 OJ 2.0 26.06
6 VC 2.0 26.14

(同样的汇总用 dplyr 写是 group_by()summarise(),见 /r/tidyverse/dplyr-grouping/。)

画成分组柱状图:

ggplot(tg, aes(x = factor(dose), y = len, fill = supp)) +
geom_col(position = "dodge")
图形输出:3 组 x 位置(0.5 / 1 / 2),每组两根并排柱子,OJ 为桃红、VC 为青绿,
右侧有标题为 supp 的图例。0.5 剂量下 OJ(13.23)明显高于 VC(7.98);
1.0 剂量下差距缩小到 22.70 对 16.77;2.0 剂量下两根柱子几乎等高(26.06 与 26.14)。

position 的四个取值决定了多组数据怎么摆:

取值 效果 什么时候用
"stack" 堆叠(柱状图的默认值) 各组之和本身有意义,比如总支出
"dodge" 并排 要比较同一类别内部各组的高低
"fill" 堆叠后归一到 1 只看构成比例,不看绝对量
"identity" 重叠 一般不用,除非配合透明度

注意 geom_col()geom_bar() 的默认位置都是 "stack",所以只写 fill = supp 而不写 position 时,柱子会自动堆起来。堆叠柱状图有个先天缺陷:只有最下面一段有统一的基线,上面几段的长度随下层的值浮动,跨类别的比较根本读不出来。真想比较构成,用 position = "fill" 加上百分比刻度 scale_y_continuous(labels = scales::percent)

并排柱子的间距可以用 width 调,position_dodge(width = 0.9) 才能让两根柱子的宽度和间距精确对齐——直接写 position = "dodge" 用的是默认宽度。

折线图表达的是「沿着某个有序变量变化」,所以 x 必须是数值或时间,类别型的 x 用折线图没有意义。

ggplot(airquality, aes(x = Day, y = Temp, color = factor(Month))) +
geom_line()
图形输出:5 条彩色折线,每条对应一个月份(5–9),右侧有标题为 factor(Month) 的图例。
x 轴 Day 范围 1–31,y 轴温度范围 56–97 °F。5 月的线整体最低(月均约 66 °F),
7 月和 8 月最高(月均约 84 °F),9 月回落到 77 °F 附近。
所有曲线都逐日上下抖动,单日极值从 56 °F 到 97 °F,跨度比月均值的差异更大。

这里没有写 group,ggplot2 是按颜色自动分组的:映射到 color、linetype 等属性的变量,同时也会成为分组变量。反过来说,如果 x 是因子、又没有映射任何分组属性,默认分组会把每个因子水平当成一组,线段就画不出来。手工汇总后画折线是最常见的触发场景:

g <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
# dose 转成因子后 x 轴变成离散的,必须显式指定 group = 1
ggplot(g, aes(x = factor(dose), y = len, group = 1)) +
geom_line() +
geom_point(size = 3)
图形输出:3 个蓝色圆点(10.61、19.74、26.10)由两条线段连接,从左下到右上。
x 轴为因子 factor(dose),y 轴为平均牙长 len,无图例。
把 group = 1 去掉后,图上只剩 3 个孤立的点,没有任何线段。

另一种情况是数值 x 但一个 x 对应多个观测。sleep 数据里每只动物在两种药物下各测一次,要连接同一只动物的两个点,就得按个体分组:

head(sleep)
extra group ID
1 0.7 1 1
2 -1.6 1 2
3 -0.2 1 3
4 -1.2 1 4
5 -0.1 1 5
6 3.4 1 6
ggplot(sleep, aes(x = group, y = extra, group = ID)) +
geom_line(alpha = 0.5) +
geom_point()
图形输出:x 轴 group 只有 1 和 2 两个刻度,每侧 10 个点,
10 条灰色半透明线段把同一只动物的两次测量连起来。
多数线段向右上方倾斜(第二组的睡眠时间增加),少数几乎持平。

箱线图一张图就能给出中位数、四分位距和离群点,适合比较多个组的分布:

ggplot(ToothGrowth, aes(x = factor(dose), y = len)) +
geom_boxplot()
图形输出:3 个箱体,中位数依次抬高,大约在 10、19、26 附近。箱体的上下边是
Q1 和 Q3,中间的粗线是中位数,须延伸到 1.5 倍四分位距以内最远的那个点,
超出这个范围的观测值画成实心圆点(离群点)。0.5 剂量组的箱体最长
(牙长从 4.2 跨到 21.5),2.0 组最紧凑,剂量越高牙长越稳定。

coef 参数控制须的长度(默认 1.5 倍 IQR,写 coef = 2 就放宽到 2 倍)。不想让离群点出现在图上,用 outlier.shape = NA 关掉——但只是不画,数据并没有被剔除,分析时该处理还得处理。

须的位置不是凭感觉定的:下须到「Q1 减 1.5 倍 IQR」,上须到「Q3 加 1.5 倍 IQR」,超出这个范围的观测值才画成离群点。按剂量分组手算一遍:

library(dplyr)
ToothGrowth |>
group_by(dose) |>
summarise(Q1 = quantile(len, 0.25), Q3 = quantile(len, 0.75), IQR = IQR(len),
upper = Q3 + 1.5 * IQR(len), n_out = sum(len > Q3 + 1.5 * IQR(len)),
.groups = "drop") |>
mutate(across(where(is.numeric), \(x) round(x, 2))) |>
as.data.frame()
dose Q1 Q3 IQR upper n_out
1 0.5 7.22 12.25 5.03 19.79 1
2 1.0 16.25 23.38 7.12 34.06 0
3 2.0 23.53 27.83 4.30 34.28 0

只有 0.5 剂量组出现一个离群点,值是 21.5。离群点不等于错误数据,它只表示这个观测值离主体较远。低剂量组里牙齿长得偏快的个体是真实的生物学变异,直接删除会引入偏差。该不该处理,取决于这个值是不是测量错误,而不是它离群。

箱线图也不是万能的。它不显示样本量,也不显示分布形状:两个箱体长得一模一样,可能一边是双峰,一边是均匀分布。每组观测数少于 10 时,四分位数本身就不稳定,箱体的位置会随抽样大幅摆动,这时候直接画点(geom_jitter())或者用 geom_violin() 更诚实。varwidth = TRUE 能让箱体宽度与样本量成正比,至少把「每组有多少数据」这件事暴露出来。

直方图(histogram)把一个连续变量的取值范围切成若干区间,数出每个区间里的观测数。用 geom_histogram(),区间个数由 binsbinwidth 控制。它没有默认的最优值,选不同的值会看到不同的分布形状

for (b in c(4, 10, 25)) {
h <- hist(ToothGrowth$len,
breaks = seq(min(ToothGrowth$len), max(ToothGrowth$len), length.out = b + 1),
plot = FALSE)
cat("bins =", b, ":", h$counts, "\n")
}
bins = 4 : 15 15 22 8
bins = 10 : 5 7 3 7 8 5 9 11 3 2
bins = 25 : 2 2 2 1 5 2 1 1 3 3 3 3 2 2 3 3 4 5 6 2 0 2 1 1 1

bins = 4 只给出四段,中间的起伏全被抹平。bins = 10 时能看出一段密集、一段稀疏。bins = 25 时区间多到大量计数只剩 1 到 3,形状开始随抽样抖动,那些高低起伏是噪声而不是分布特征。用 hist(..., plot = FALSE) 先把计数打印出来再决定 bins,比反复画图试快。

ggplot(ToothGrowth, aes(x = len)) +
geom_histogram(bins = 10, fill = "grey70", color = "white")

color = "white" 给柱子加白色描边,相邻柱子之间才有分界。柱宽默认等于区间宽度、柱子之间不留空隙,这是直方图与柱状图在视觉上的主要区别:柱状图的柱宽可以任意指定,直方图的柱宽由数据范围和区间数共同决定。

只画均值不画离散程度,读者无法判断组间差异是否可靠。geom_errorbar() 需要显式给出上下界,所以先算出汇总量:

tg_sum <- ToothGrowth |>
group_by(supp, dose) |>
summarise(mean = mean(len), sd = sd(len), n = n(), .groups = "drop") |>
mutate(sem = sd / sqrt(n))
tg_sum |> mutate(across(c(mean, sd, sem), \(x) round(x, 2))) |> as.data.frame()
supp dose mean sd n sem
1 OJ 0.5 13.23 4.46 10 1.41
2 OJ 1.0 22.70 3.91 10 1.24
3 OJ 2.0 26.06 2.66 10 0.84
4 VC 0.5 7.98 2.75 10 0.87
5 VC 1.0 16.77 2.52 10 0.80
6 VC 2.0 26.14 4.80 10 1.52
ggplot(tg_sum, aes(x = factor(dose), y = mean, color = supp, group = supp)) +
geom_errorbar(aes(ymin = mean - sem, ymax = mean + sem), width = 0.1) +
geom_line() +
geom_point(size = 3)

yminymax 必须写在 aes() 里,ggplot2 不会替你算。误差棒的长度取决于传进去的是哪个量:

传什么 含义 适用场景
sd 标准差 描述个体变异范围
sem 标准误(sd / √n) 描述均值估计的精度
置信区间半宽 95% CI 统计推断,与 p 值一致

n = 10 时 sem = sd / 3.162。OJ 组在 0.5 剂量下 sd 是 4.46、sem 只有 1.41,误差棒不到标准差的三分之一。用 sd 还是 sem 必须在图注里写明,两者相差几倍,读者从图上分辨不出来;审稿人要求误差棒时通常指的是 sem 或 95% CI。width 控制两端横杠的长度,设为 0 时误差棒只剩一条竖线,容易和折线本身混淆。

几条经验判断:

数据形态 用什么 注意
两个连续变量 geom_point() 重叠多时加 alpha 或换 geom_jitter()
离散 x 加连续 y,要比较分布 geom_boxplot() 每组少于 10 个观测就改画点
一个汇总数值(计数、均值、总和) geom_col() 先自己汇总,geom_bar() 只数行数
多组比较同一个汇总值 geom_col()position = "dodge" position_dodge(width = 0.9) 对齐柱宽
x 有序(时间、剂量) geom_line() 汇总后画线要显式写 group = 1
单个连续变量的分布 geom_histogram() 先打印计数再定 bins
均值比较,要表达不确定性 geom_errorbar() ymin/ymax 必须自己算

类别超过 15 个时柱状图会糊成一片,ggplot2 3.5 起官方更推荐把类别映射到 y 轴(横向柱状图),而不是用 coord_flip(),或者干脆改用表格。

柱状图的 y 轴必须从 0 开始。 柱状图用长度编码数值,截断基线等于歪曲比例。同一组均值,y 轴取 0 到 30 与 10 到 30 是两个结论:

tg <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
ggplot(tg, aes(x = factor(dose), y = len)) +
geom_col()

y 轴从 0 起时三根柱子的高度比是 1 : 1.86 : 2.46。截断到 10 到 30 之后,0.5 剂量那根柱子从视觉上几乎消失,读起来像是「低剂量没有效果」。

要截断 y 轴,用 coord_cartesian(ylim = c(10, 30)),它只放大视野,数据一行不动。不要用 ylim()scale_y_continuous(limits = )——这两个会把范围外的观测直接删掉,并打印 Removed 32 rows containing missing values or values outside the scale range 这类警告。凡是靠长度表达数值的图形(柱状图、面积图、误差棒的下界)都不该截断;折线图和散点图靠位置编码,截断可以接受,但要在图注里说明。

scale_x_discrete(labels = ) 的标签数量与因子水平数不一致时不会报错,只会静默错位。 三个水平给了两个标签,标签会整体前移,图能画出来但意思全错。用命名向量最稳妥:

scale_x_discrete(labels = c("0.5" = "低剂量", "1" = "中剂量", "2" = "高剂量"))

名字对不上因子水平时会直接报错,而不是默默把标签挪错位置。

下一篇讲分面与拼图,见 /r/visualization/facets/。同样的这几种图形,在 Python 里用 Seaborn 画会省掉一部分手工汇总——它的 hue 参数把分组和上色合成了一步,可以对照 /python/visualization/seaborn/ 看两种接口的取舍。