← 返回 Tessera
柱状6 张图mtcars(Tessera Toy,32 行)· hair_eye_color(Tessera Toy,32 行长表)2026-08-11

bar

不同类别的数值谁高谁低;加入第二个分类后,是并排比较还是查看构成?

需要的输入
原始计数只需 1 个分类变量;已有柱高时再加 1 个非负数值;分组或堆叠时再加 1 个分类变量
示例数据
mtcars(Tessera Toy,32 行)· hair_eye_color(Tessera Toy,32 行长表)
依赖
ggplot2 · ggpubr · dplyr · scales · biopalette
配色
walter_white2
成图预览另有 5 张在配方里
bar

什么时候用它

柱状图比较离散类别对应的数值。类别之间没有连续过程,柱与柱之间的空隙正是在表达这一点;如果横轴是日期、年龄或剂量,通常应先考虑折线或点图。

一根柱最适合回答“谁高、谁低、差多少”。加入第二个分类后有两条不同路线:

  • 并排柱保留共同零基线,适合比较各组绝对值。
  • 堆叠柱把各部分加成总量,适合看构成;只有最底层拥有共同基线,中间区块不适合精确比较。

柱图不是连续数据分布的替代品。每组有很多原始观测时,只画均值柱会藏掉离散程度、样本量和异常值;应优先用点图、箱线图、小提琴图或点估计加误差棒。

geom_bar() 还是 geom_col()

两者的区别不在外观,而在谁负责计算柱高

  • geom_bar() 默认 stat = "count",输入是一行一个观测的原始类别,它替你数行数。
  • geom_col() 等价于 geom_bar(stat = "identity"),输入已经有 y,柱高就是数据里的值。

hair_eye_colorFreq 已经是频数。若直接写 geom_bar(),R 数到的是每种组合有几行,而不是学生人数;图可以正常生成,含义却完全错了。因此第一种用 mtcars 演示原始行计数,后面的汇总表全部使用 geom_col()

频数表也可以写 geom_bar(aes(weight = Freq)),此时统计层会按类别累加权重。这在临时探索时很方便,但正式 Recipe 更倾向先把汇总表算出来再用 geom_col():中间数据可以检查、复用,缺组也更容易发现。

怎么读

  1. 先确认零基线。 柱长依靠长度编码,截断纵轴会夸大差异。
  2. 比较柱端位置。 单柱和并排柱共享基线,柱端越容易对齐,比较越可靠。
  3. 堆叠图先看总长。 整根柱回答总量,各区块回答组成。
  4. 中间区块只作粗略比较。 它们上下边界都在移动,没有共同起点。
  5. 百分比柱只表达构成。 每根都是 100%,看不出样本量大小;必要时另画总量或在图注中给出 n

常见陷阱

  • geom_bar() 重数汇总表。 一行代表 68 人时,它仍只数成 1。
  • 柱轴不从零开始。 长度编码失去共同起点,视觉差异会被人为放大。
  • 擅自排序。 名义类别可按数值排序;时间、剂量、流程阶段和有自然顺序的等级不能为了好看重排。
  • 分组太多。 每组四五根并排柱以后,读者既找不到配对,也读不动图例;改用分面、点图或热图。
  • 堆叠顺序与图例相反。 每次查颜色都要在脑中翻转顺序。
  • 100% 掩盖缺组。 少一类后剩余部分仍会归一化成 100%;必须在归一化之前检查组合是否完整。
  • 每段都塞标签。 小区块放不下文字时应省略,让 tooltip、图例或表格承担精确读数。
  • 均值柱冒充原始数据。 没有误差、样本量与分布的均值柱,往往比不画更容易误导。

配色

柱子的 fill 表达类别,因此使用定性色板 walter_white2。单序列只需一个颜色;并排或堆叠时,每个水平固定使用一个颜色,图例顺序必须与分组或堆叠顺序一致。

相邻色块比相隔的散点更考验颜色边界。堆叠柱使用细白线分隔区块,但白线只负责断开边界,不能补救本身难以区分的配色。色板少于类别数时,不应循环颜色;应减少重点类别、合并有业务意义的类别,或明确将其余类别作为中性上下文。

配方

方法绘图系统什么时候选它
Ageom_bar()ggplot2数据是一行一个原始观测,需要现场统计各类别有多少行
Bgeom_col()ggplot2每个类别已有一个汇总值;柱高直接使用数据中的数值
Creorder() + coord_flip()ggplot2类别有明确的大小排名,或名称较长,横向排序后更容易比较
Dposition_dodge()ggplot2第二个分类只有少数水平,需要在每个主类别内直接比较绝对值
Eposition_stack()ggplot2同时关心各部分绝对量与总量;各段相加就是整根柱高
F百分比堆叠ggplot2只比较构成,不比较总量;先明确归一化,再交给 geom_col()

数据与公共样式

hair_eye_color 是一个完整的 Hair × Eye × Sex × Freq 长表。下面先检查 4 × 4 × 2 个组合是否齐全,再派生每种柱图需要的汇总表。

library(dplyr)
library(ggplot2)
library(ggpubr)
library(scales)
library(biopalette)

d <- read.csv("content/tessera/data/csv/hair_eye_color.csv")
cars <- read.csv("content/tessera/data/csv/mtcars.csv")

hair_levels <- c("Black", "Brown", "Red", "Blond")
eye_levels <- c("Brown", "Blue", "Hazel", "Green")
sex_levels <- c("Male", "Female")

if (nrow(d) != length(hair_levels) * length(eye_levels) * length(sex_levels) ||
    any(count(d, Hair, Eye, Sex)$n != 1)) {
  stop("Hair × Eye × Sex 组合不完整")
}

d <- d |>
  mutate(
    Hair = factor(Hair, levels = hair_levels),
    Eye = factor(Eye, levels = eye_levels),
    Sex = factor(Sex, levels = sex_levels)
  )

hair_total <- d |>
  summarise(Freq = sum(Freq), .by = Hair)

hair_sex <- d |>
  summarise(Freq = sum(Freq), .by = c(Hair, Sex))

hair_eye <- d |>
  summarise(Freq = sum(Freq), .by = c(Hair, Eye))

category_colors <- setNames(
  get_palette("walter_white2", type = "qualitative")[1:4],
  eye_levels
)

bar_theme <- theme_pubr(base_size = 13, legend = "right") +
  theme(
    panel.grid.major.y = element_line(color = "#E5E3DC", linewidth = 0.35),
    panel.grid.major.x = element_blank(),
    axis.line = element_line(color = "#333330", linewidth = 0.45),
    axis.ticks = element_line(color = "#333330", linewidth = 0.4),
    plot.title = element_text(face = "bold", size = 15, hjust = 0),
    plot.subtitle = element_text(color = "grey35", hjust = 0),
    legend.title = element_text(face = "bold"),
    plot.margin = margin(14, 18, 12, 12)
  )

A · geom_bar():从原始观测自动计数

mtcars 一行是一款汽车,因此 geom_bar() 可以直接统计 4、6、8 缸各有多少款。这里没有 y;柱高来自绘图层计算出的 count,标签也必须通过 after_stat(count) 读取同一个统计结果。

#| fig: count
#| fig-width: 7.6
#| fig-height: 5.1
cars$cyl <- factor(cars$cyl, levels = c(4, 6, 8))

ggplot(cars, aes(cyl)) +
  geom_bar(width = 0.68, fill = "#5AB5BF") +
  geom_text(
    stat = "count",
    aes(label = after_stat(count)),
    vjust = -0.55,
    size = 3.7,
    fontface = "bold"
  ) +
  scale_y_continuous(
    limits = c(0, NA),
    breaks = seq(0, 15, 5),
    expand = expansion(mult = c(0, 0.1))
  ) +
  labs(
    title = "Cars by cylinder count",
    subtitle = "geom_bar() counts one-row-per-car observations",
    x = "Cylinders",
    y = "Cars"
  ) +
  bar_theme
bar — count
bar-count

B · geom_col():数据顺序就是展示顺序

四种发色有明确、熟悉的原始顺序,这里不按人数擅自重排。geom_col() 直接读取汇总后的 Freq,顶部留出空间给数值标签。

#| fig: basic
#| fig-width: 7.6
#| fig-height: 5.1
ggplot(hair_total, aes(Hair, Freq)) +
  geom_col(width = 0.68, fill = "#5AB5BF") +
  geom_text(aes(label = Freq), vjust = -0.55, size = 3.7, fontface = "bold") +
  scale_y_continuous(
    limits = c(0, NA),
    expand = expansion(mult = c(0, 0.09))
  ) +
  labs(
    title = "Students by hair color",
    subtitle = "Original category order retained",
    x = "Hair color",
    y = "Students"
  ) +
  bar_theme
bar — basic
bar-basic

C · 排序与横向:只在问题是排名时使用

如果问题明确变成“哪种发色人数最多”,排序才有意义。长类别名也更适合横向;这与 evanverse::plot_bar(horizontal = TRUE, sort = TRUE) 的行为一致,但这里把因子重排和坐标翻转显式写出来。

#| fig: horizontal
#| fig-width: 7.6
#| fig-height: 5.1
ranked <- hair_total |>
  mutate(Hair = reorder(Hair, Freq))

ggplot(ranked, aes(Hair, Freq)) +
  geom_col(width = 0.64, fill = "#5AB5BF") +
  geom_text(aes(label = Freq), hjust = -0.35, size = 3.7, fontface = "bold") +
  scale_y_continuous(
    limits = c(0, NA),
    expand = expansion(mult = c(0, 0.1))
  ) +
  coord_flip(clip = "off") +
  labs(
    title = "Students by hair color",
    subtitle = "Sorted only because the question is a ranking",
    x = NULL,
    y = "Students"
  ) +
  bar_theme
bar — horizontal
bar-horizontal

D · 并排柱:比较第二个分类的绝对值

Male 与 Female 只有两个水平,并排后仍能快速配对。position_dodge() 的宽度同时传给柱和标签,避免文字落在另一根柱上。

#| fig: grouped
#| fig-width: 8
#| fig-height: 5.2
dodge <- position_dodge(width = 0.74)
sex_colors <- setNames(category_colors[1:2], sex_levels)

ggplot(hair_sex, aes(Hair, Freq, fill = Sex)) +
  geom_col(width = 0.66, position = dodge) +
  geom_text(
    aes(label = Freq),
    position = dodge,
    vjust = -0.5,
    size = 3.25,
    fontface = "bold"
  ) +
  scale_y_continuous(
    limits = c(0, NA),
    expand = expansion(mult = c(0, 0.11))
  ) +
  scale_fill_manual(values = sex_colors, breaks = sex_levels) +
  labs(
    title = "Hair color counts by sex",
    subtitle = "Side-by-side bars preserve a shared zero baseline",
    x = "Hair color",
    y = "Students",
    fill = "Sex"
  ) +
  bar_theme
bar — grouped
bar-grouped

E · 堆叠柱:部分相加得到总量

这一版同时保留每种眼色的频数与每种发色的总人数。细白线帮助辨认相邻区块;不在每段内塞数字,因为中间段本来就不适合精确横向比较。

#| fig: stacked
#| fig-width: 8
#| fig-height: 5.2
ggplot(hair_eye, aes(Hair, Freq, fill = Eye)) +
  geom_col(
    width = 0.68,
    color = "white",
    linewidth = 0.6,
    position = position_stack(reverse = TRUE)
  ) +
  geom_text(
    data = hair_total,
    aes(Hair, Freq, label = Freq),
    inherit.aes = FALSE,
    vjust = -0.55,
    size = 3.5,
    fontface = "bold"
  ) +
  scale_y_continuous(
    limits = c(0, NA),
    expand = expansion(mult = c(0, 0.09))
  ) +
  scale_fill_manual(values = category_colors, breaks = eye_levels) +
  labs(
    title = "Eye color counts",
    subtitle = "Stacked within hair color · totals shown above",
    x = "Hair color",
    y = "Students",
    fill = "Eye color"
  ) +
  bar_theme
bar — stacked
bar-stacked

F · 100% 堆叠柱:主动放弃总量,只比较构成

先在每种发色内部计算比例,而不是让绘图层隐式归一化。这样 Percent 能检查、能复用、也能直接用于标签。横向布局让四个发色名称与共同的 0–100% 标尺更容易扫描。

#| fig: percentage
#| fig-width: 8
#| fig-height: 5.2
hair_percent <- hair_eye |>
  group_by(Hair) |>
  mutate(Percent = Freq / sum(Freq)) |>
  ungroup() |>
  mutate(Hair = factor(Hair, levels = rev(hair_levels)))

ggplot(hair_percent, aes(Hair, Percent, fill = Eye)) +
  geom_col(
    width = 0.68,
    color = "white",
    linewidth = 0.65,
    position = position_stack(reverse = TRUE)
  ) +
  geom_text(
    aes(label = ifelse(Percent >= 0.08, percent(Percent, accuracy = 1), "")),
    position = position_stack(vjust = 0.5, reverse = TRUE),
    color = "#262622",
    size = 3.5,
    fontface = "bold"
  ) +
  scale_y_continuous(
    breaks = seq(0, 1, 0.25),
    labels = percent_format(accuracy = 1),
    expand = c(0, 0)
  ) +
  scale_fill_manual(values = category_colors, breaks = eye_levels) +
  coord_flip(clip = "off") +
  labs(
    title = "Eye color composition by hair color",
    subtitle = "Sexes combined · each bar sums to 100%",
    x = NULL,
    y = "Composition",
    fill = "Eye color"
  ) +
  bar_theme +
  theme(
    panel.grid.major.x = element_line(color = "#E5E3DC", linewidth = 0.35),
    panel.grid.major.y = element_blank(),
    axis.line.y = element_blank()
  )
bar — percentage
bar-percentage

六种画法怎么选

  1. 一行一个原始观测,要现场数类别 → A。
  2. 每类已有一个值,保留自然顺序 → B。
  3. 问题就是排名,或类别名很长 → C。
  4. 比较少数分组的绝对值 → D。
  5. 同时看部分与总量 → E。
  6. 只看构成比例 → F;同时提供总量,避免把比例变化误读成数量变化。
运行环境5 个包 · 2026-08-11T17:13:52.892+0800

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • biopalette 0.1.0
  • dplyr 1.2.1
  • ggplot2 4.0.3
  • ggpubr 0.6.2
  • scales 1.4.0