ggplot2常用几何对象:柱状图、折线图与箱线图
选几何对象(geom)之前先回答一个问题:数据里的一行,对应图上的什么?是一个点、一根柱子,还是一条线的转折点。回答清楚了,geom 基本就定了;回答不清楚,画出来的图通常也不能用。
散点图:geom_point 与 geom_jitter
Section titled “散点图:geom_point 与 geom_jitter”两个连续变量之间的关系用 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
Section titled “趋势线:geom_smooth”geom_smooth() 默认按 x 排序后拟合一条平滑曲线,适合展示趋势而不是精确数值。method 有三个常用取值:
"loess":默认值,局部加权回归。点数少于 1000 时是首选,span参数(0.2–1)控制平滑程度,越大越平。"lm":最小二乘直线,配合formula = y ~ x使用,可以写y ~ poly(x, 2)拟合二次曲线。"gam":广义可加模型,点数超过 1000 时用它替代 loess。
一个常见的误用是拿 loess 曲线去外推。平滑曲线在数据范围的两端没有数据支撑,置信带会张得很开,那一段的走势基本是模型编出来的。如果审稿人问「曲线右端为什么翘起来」,那是拟合方式的问题,不是数据的问题。
柱状图:geom_bar 与 geom_col
Section titled “柱状图:geom_bar 与 geom_col”这是概念上最容易混的一对,差别在于要不要做统计变换(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 811 7 14如果手里已经算好了均值或总数,用 geom_col()。硬撑着给 geom_bar() 加 stat = "identity" 也能跑,但读代码的人要多想一步。反过来,给 geom_col() 的 y 传原始观测值(一个 x 类别下有多行),它会把柱子叠起来,通常不是你想要的。
柱状图只适合比较一个数值指标,不要用它表现两个连续变量的关系,那是散点图的活。
分组柱状图与 position
Section titled “分组柱状图与 position”ToothGrowth 记录的是维生素 C 对豚鼠牙生长的影响:len 是牙长,supp 是给药方式(OJ 橙汁 / VC 抗坏血酸),dose 是每日剂量。先按两个分组变量算平均牙长:
tg <- aggregate(len ~ supp + dose, data = ToothGrowth, FUN = mean)tg supp dose len1 OJ 0.5 13.232 VC 0.5 7.983 OJ 1.0 22.704 VC 1.0 16.775 OJ 2.0 26.066 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" 用的是默认宽度。
折线图:geom_line 与分组
Section titled “折线图:geom_line 与分组”折线图表达的是「沿着某个有序变量变化」,所以 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 = 1ggplot(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 ID1 0.7 1 12 -1.6 1 23 -0.2 1 34 -1.2 1 45 -0.1 1 56 3.4 1 6ggplot(sleep, aes(x = group, y = extra, group = ID)) + geom_line(alpha = 0.5) + geom_point()图形输出:x 轴 group 只有 1 和 2 两个刻度,每侧 10 个点,10 条灰色半透明线段把同一只动物的两次测量连起来。多数线段向右上方倾斜(第二组的睡眠时间增加),少数几乎持平。箱线图:geom_boxplot 与异常值
Section titled “箱线图:geom_boxplot 与异常值”箱线图一张图就能给出中位数、四分位距和离群点,适合比较多个组的分布:
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_out1 0.5 7.22 12.25 5.03 19.79 12 1.0 16.25 23.38 7.12 34.06 03 2.0 23.53 27.83 4.30 34.28 0只有 0.5 剂量组出现一个离群点,值是 21.5。离群点不等于错误数据,它只表示这个观测值离主体较远。低剂量组里牙齿长得偏快的个体是真实的生物学变异,直接删除会引入偏差。该不该处理,取决于这个值是不是测量错误,而不是它离群。
箱线图也不是万能的。它不显示样本量,也不显示分布形状:两个箱体长得一模一样,可能一边是双峰,一边是均匀分布。每组观测数少于 10 时,四分位数本身就不稳定,箱体的位置会随抽样大幅摆动,这时候直接画点(geom_jitter())或者用 geom_violin() 更诚实。varwidth = TRUE 能让箱体宽度与样本量成正比,至少把「每组有多少数据」这件事暴露出来。
直方图:bins 决定了你看到什么
Section titled “直方图:bins 决定了你看到什么”直方图(histogram)把一个连续变量的取值范围切成若干区间,数出每个区间里的观测数。用 geom_histogram(),区间个数由 bins 或 binwidth 控制。它没有默认的最优值,选不同的值会看到不同的分布形状:
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 8bins = 10 : 5 7 3 7 8 5 9 11 3 2bins = 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 1bins = 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" 给柱子加白色描边,相邻柱子之间才有分界。柱宽默认等于区间宽度、柱子之间不留空隙,这是直方图与柱状图在视觉上的主要区别:柱状图的柱宽可以任意指定,直方图的柱宽由数据范围和区间数共同决定。
误差棒:让不确定性可见
Section titled “误差棒:让不确定性可见”只画均值不画离散程度,读者无法判断组间差异是否可靠。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 sem1 OJ 0.5 13.23 4.46 10 1.412 OJ 1.0 22.70 3.91 10 1.243 OJ 2.0 26.06 2.66 10 0.844 VC 0.5 7.98 2.75 10 0.875 VC 1.0 16.77 2.52 10 0.806 VC 2.0 26.14 4.80 10 1.52ggplot(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)ymin 和 ymax 必须写在 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(),或者干脆改用表格。
两个反复出现的坑
Section titled “两个反复出现的坑”柱状图的 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/ 看两种接口的取舍。