1. 棒棒糖图到底是个什么东西
先把概念说清楚。棒棒糖图,英文里叫 Lollipop Chart,也有人管它叫火柴杆图。名字听着挺花哨,本质上就是把柱状图的柱子换成了一根细线加一个圆点——细线从基线出发,顶端顶着一个实心圆点,看起来就像一根棒棒糖插在坐标轴上。
它解决的核心问题其实很朴素:当你有一组分类数据需要比较大小,但类别数量偏多、数值差异又比较悬殊的时候,传统柱状图会显得特别笨重。十几根粗柱子挤在一起,视觉上全是色块,读者根本抓不住重点。棒棒糖图把"面"压缩成"线+点",留白一下子多了出来,眼睛能顺着圆点的位置快速扫出一条趋势线。
这个图在 R 语言里实现起来并不复杂,ggplot2配合geom_segment()和geom_point()两个图层就能搭出来,进阶一点还可以用ggalt包里的geom_lollipop()一步到位。适合谁来学?我的判断是:已经会画基础柱状图、想给图表做减法的数据分析从业者;做报告时被吐槽"图太丑"的职场人;以及正在学ggplot2图层语法、想找一个综合练习案例的 R 语言学习者。它不需要你懂什么高深统计,但需要你对ggplot2的映射逻辑有基本感觉。
我个人的使用场景主要集中在两类:一是排名类数据,比如各渠道的转化率对比、各产品的销量排行;二是时间序列里挑几个关键节点做对比。这两类场景用棒棒糖图,信息密度和美观度都比柱状图高一个档次。
2. 为什么我建议你用棒棒糖图而不是柱状图
2.1 视觉减法的底层逻辑
柱状图的问题在于"墨水量"太高。每一根柱子都是一个实心矩形,它占用的像素面积和数值大小成正比,但人眼对面积的感知其实是不如对位置敏感的。这是数据可视化领域一个老生常谈的结论:位置编码优于长度编码,长度编码优于面积编码。棒棒糖图把柱子换成线,实际上是把"面积比较"降级成了"位置比较",读者只需要看圆点落在哪个刻度上就行,判断速度更快。
另一个隐性好处是留白。柱状图里柱子挨着柱子,视觉上很堵。棒棒糖图的线很细,点很小,图表里大量区域是空的,这种呼吸感在排版密集的报告里特别值钱。我做过一次对比测试,同一组 20 个类别的数据,柱状图打印出来像一堵墙,棒棒糖图则清爽得多,同事第一眼就能找到最大值和最小值。
2.2 什么时候该用,什么时候别用
不是所有场景都适合。我的经验判断标准是这样的:
| 场景特征 | 推荐棒棒糖图 | 推荐柱状图 |
|---|---|---|
| 类别数量 | 10 个以上 | 5 个以内 |
| 数值差异 | 悬殊,有极端值 | 相对均匀 |
| 是否需要强调总量 | 不需要 | 需要 |
| 图表用途 | 排名、对比 | 构成、累积 |
| 输出媒介 | 屏幕、PPT | 打印、海报 |
关键点在于:如果你想让读者感受"总量"或者"堆积构成",柱状图的面积优势就体现出来了,这时候硬上棒棒糖图反而丢失信息。但如果你只是想表达"谁高谁低",棒棒糖图是更聪明的选择。
2.3 和哑铃图、子弹图的区别
初学者容易把这几个搞混。哑铃图是两根线连两个点,用来比较两个时间点或两组数值的差异;子弹图是一根粗条加一根细条,用来对比实际值和目标值;棒棒糖图只有一根线一个点,纯粹表达单一维度的数值大小。搞清楚这个区别,你就不会在需要对比两组数据的时候错用棒棒糖图了。
3. 动手之前:环境与数据准备
3.1 包的选择与安装
核心依赖就两个:ggplot2和dplyr。前者负责画图,后者负责数据整理。如果你想要更省事的封装函数,可以额外装一个ggalt,它提供了geom_lollipop(),但我不太推荐新手一上来就用它,因为自己用geom_segment()搭一遍,你对图层语法的理解会深很多。
install.packages(c("ggplot2", "dplyr")) # 可选 install.packages("ggalt")加载的时候我习惯把tidyverse整个拉进来,省得一个个写:
library(tidyverse)3.2 数据格式要求
棒棒糖图对数据的要求很简单:一列分类变量,一列数值变量。但有个坑必须提前说——分类变量的排序。默认情况下ggplot2会按字母顺序排列分类轴,但排名类图表你肯定希望按数值大小排。所以画图之前,一定要用fct_reorder()或者reorder()把因子水平重新排一遍。
我一般用fct_reorder(),因为它语法更直观:
df <- df %>% mutate(category = fct_reorder(category, value))这行的意思是:把category这个因子的水平,按照对应的value大小重新排序。默认是升序,如果你想让最大值在顶部,后面在坐标轴那里再翻转就行。
3.3 一个模拟数据集
为了后面演示方便,我造一组模拟数据。假设这是某公司 12 个渠道的月度转化率:
set.seed(42) df <- tibble( channel = paste0("渠道", LETTERS[1:12]), rate = c(3.2, 5.8, 4.1, 7.6, 2.9, 6.3, 8.9, 4.7, 5.2, 9.4, 3.8, 6.9) ) %>% mutate(channel = fct_reorder(channel, rate))这组数据最小值 2.9,最大值 9.4,差异接近 3 倍,正好适合用棒棒糖图来展示。
4. 从零搭一张棒棒糖图
4.1 最基础的版本
先看最朴素的实现,理解图层叠加的逻辑:
ggplot(df, aes(x = channel, y = rate)) + geom_segment(aes(xend = channel, yend = 0), color = "grey60", linewidth = 0.8) + geom_point(size = 4, color = "#2E86AB") + coord_flip() + theme_minimal()逐行拆解一下。geom_segment()画的是线段,x和xend都是分类变量本身(因为线段是垂直的),y是数值,yend = 0表示线段从 0 一直延伸到数值所在的位置。geom_point()在数值位置画一个圆点。coord_flip()把坐标轴翻转,让分类变量跑到纵轴上,这样标签横向排列,读起来更舒服。
这里有个细节:linewidth参数在旧版本ggplot2里叫size,如果你用的是 3.4.0 之前的版本,得改成size = 0.8,否则会报弃用警告。这个坑我踩过不止一次。
4.2 让圆点大小映射数值
基础版所有圆点一样大,其实浪费了一个视觉通道。我们可以让圆点的大小也跟数值挂钩,形成双重编码:
ggplot(df, aes(x = channel, y = rate)) + geom_segment(aes(xend = channel, yend = 0), color = "grey70", linewidth = 0.6) + geom_point(aes(size = rate), color = "#2E86AB", alpha = 0.85) + scale_size_continuous(range = c(3, 8), guide = "none") + coord_flip() + theme_minimal()scale_size_continuous()里的range控制圆点直径的最小值和最大值,guide = "none"表示不显示大小图例——因为数值本身已经在横轴上了,再显示图例就是冗余信息。这个技巧在可视化里叫"去除冗余编码",能让图表更干净。
4.3 配色与主题微调
默认的灰色线段有点寡淡,我一般会给线段也上色,但颜色要比圆点浅,形成层次:
ggplot(df, aes(x = channel, y = rate)) + geom_segment(aes(xend = channel, yend = 0, color = rate), linewidth = 0.8, show.legend = FALSE) + geom_point(aes(color = rate), size = 5, show.legend = FALSE) + scale_color_gradient(low = "#A8DADC", high = "#1D3557") + coord_flip() + theme_minimal(base_size = 13) + theme( panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), axis.title = element_blank() )这里用了渐变配色,数值越大颜色越深。panel.grid.major.y = element_blank()去掉了横向网格线,因为线段本身就是从基线出发的,网格线反而干扰视线。axis.title = element_blank()去掉了坐标轴标题,因为渠道名和转化率这两个信息从上下文就能看出来,标题是多余的。
提示:渐变配色虽然好看,但如果你的图表要黑白打印,渐变会糊成一片。这种情况建议改用单一颜色加透明度映射,或者干脆用离散色阶。
5. 进阶玩法:让图表会说话
5.1 添加数值标签
光有圆点还不够,读者可能想知道具体数值。加标签用geom_text():
ggplot(df, aes(x = channel, y = rate)) + geom_segment(aes(xend = channel, yend = 0), color = "grey70", linewidth = 0.6) + geom_point(color = "#2E86AB", size = 5) + geom_text(aes(label = sprintf("%.1f%%", rate)), hjust = -0.3, size = 3.5, color = "grey30") + coord_flip() + ylim(0, max(df$rate) * 1.15) + theme_minimal()两个关键点。第一,hjust = -0.3让标签往右偏一点,避免压在圆点上。第二,ylim()手动扩展了纵轴范围,给标签留出空间,否则最长的那个标签会被裁掉。这个* 1.15的系数是我试出来的经验值,一般留 15% 的余量就够了。
5.2 分组对比:双组棒棒糖图
有时候你需要对比两组数据,比如今年和去年的转化率。这时候可以画双组棒棒糖图,两组线段用不同颜色区分:
df2 <- df %>% mutate(rate_last_year = rate * runif(n(), 0.7, 0.95)) %>% pivot_longer(cols = c(rate, rate_last_year), names_to = "year", values_to = "value") ggplot(df2, aes(x = channel, y = value, color = year)) + geom_segment(aes(xend = channel, yend = 0), position = position_dodge(width = 0.4), linewidth = 0.7) + geom_point(size = 4, position = position_dodge(width = 0.4)) + scale_color_manual(values = c("#2E86AB", "#E63946")) + coord_flip() + theme_minimal()position_dodge()是关键,它让两组线段错开,不会重叠在一起。width = 0.4控制错开的距离,太小了会挤,太大了会散,0.4 是我常用的值。
5.3 高亮特定类别
报告里经常需要突出某个重点类别,比如"渠道J 是表现最好的"。这时候可以用条件判断给特定点单独上色:
df <- df %>% mutate(highlight = ifelse(rate == max(rate), "最高", "其他")) ggplot(df, aes(x = channel, y = rate)) + geom_segment(aes(xend = channel, yend = 0, color = highlight), linewidth = 0.8, show.legend = FALSE) + geom_point(aes(color = highlight), size = 5, show.legend = FALSE) + scale_color_manual(values = c("最高" = "#E63946", "其他" = "#A8DADC")) + coord_flip() + theme_minimal()这种"一红多灰"的配色策略在数据新闻里非常常见,读者的注意力会被自动引导到那个红色的点上。比在图上画个圈或者加个箭头要优雅得多。
6. 踩过的坑与排查清单
6.1 线段从 0 出发还是从最小值出发
这是个哲学问题。从 0 出发是诚实做法,但如果所有数值都集中在 5 到 10 之间,从 0 出发会让所有线段看起来差不多长,差异被压缩了。这时候可以考虑从最小值出发,但必须在图上明确标注基线不是 0,否则就是误导。
我的做法是:如果数值差异本身够大(最大值是最小值的 2 倍以上),从 0 出发;如果差异很小,要么从 0 出发接受"看起来差不多"的事实,要么换一种图表类型。绝对不要偷偷改基线还不标注,这是数据可视化的红线。
6.2 分类标签太长怎么办
渠道名如果很长,纵轴标签会占掉大量空间。两个解决方案:一是用str_wrap()把长标签折行;二是缩短标签,把完整名称放到图注里。
df <- df %>% mutate(channel = str_wrap(channel, width = 10))str_wrap()来自stringr包,width = 10表示每行最多 10 个字符。这个函数对中文的支持一般,中文建议按字数手动截断或者用\n手动换行。
6.3 圆点被裁切
如果圆点画得比较大,而纵轴范围又刚好卡在最大值上,最上面那个圆点会被裁掉一半。解决办法是在ylim()或者scale_y_continuous()里留出余量:
scale_y_continuous(expand = expansion(mult = c(0, 0.1)))expansion(mult = c(0, 0.1))的意思是:底部不留余量(因为基线是 0),顶部留 10% 的余量。这个参数比手动算ylim更优雅。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 线段不显示 | yend没设成 0 | 检查aes(yend = 0) |
| 圆点和线段错位 | 分组变量没对齐 | 检查position_dodge的width是否一致 |
| 标签重叠 | 数值太接近 | 用ggrepel包的geom_text_repel() |
| 颜色不生效 | 颜色写在aes()外面 | 映射到数据的颜色必须写在aes()里 |
| 图例多余 | 没关掉show.legend | 加show.legend = FALSE |
| 中文乱码 | 字体没设置 | theme(text = element_text(family = "STHeiti")) |
7. 一些让图表更专业的细节
7.1 线宽和点大小的比例
线段太粗会显得笨重,太细又看不清。我的经验值是:线段linewidth在 0.6 到 1.0 之间,圆点size在 4 到 6 之间。两者的比例大概是 1:6 左右,视觉上最协调。这个没有绝对标准,多试几次就有感觉了。
7.2 网格线的取舍
theme_minimal()默认保留了横纵网格线。对于棒棒糖图,我建议去掉纵向网格线(翻转后是横向),保留横向网格线(翻转后是纵向)。因为线段本身已经起到了"引导线"的作用,再叠加网格线就是重复劳动。
7.3 排序方向
排名类图表,最大值应该在最上面。由于coord_flip()之后,因子水平的第一个会显示在最下面,所以如果你用fct_reorder()升序排列,翻转后最大值就在最上面,正好符合直觉。如果你想让最小值在最上面,就在fct_reorder()里加.desc = TRUE。
7.4 导出时的尺寸控制
ggsave()导出时,宽高比很重要。棒棒糖图因为分类标签在纵轴上,通常需要偏"高"的画布:
ggsave("lollipop.png", width = 8, height = 6, dpi = 300)如果类别特别多(比如 20 个以上),高度要相应增加,否则标签会挤在一起。我的经验公式是:高度 = 类别数 × 0.4 + 2,单位是英寸。12 个类别就是 12 × 0.4 + 2 = 6.8 英寸。
8. 从棒棒糖图延伸出去的几个方向
棒棒糖图本身不复杂,但它是理解ggplot2图层语法的一个绝佳跳板。你把这套逻辑吃透之后,可以很轻松地迁移到其他图表上。
比如把geom_segment()换成geom_rect(),就变成了条形图;把geom_point()换成geom_text(),就变成了标签图;把单组数据扩展成两组,就变成了哑铃图。核心都是"图层叠加 + 映射"这一套。
另外一个值得尝试的方向是动画。用gganimate包可以让圆点从基线"长"出来,做成一个生长动画。这个在演示的时候效果很好,但要注意别滥用——动画应该服务于信息传达,而不是为了炫技。
我还试过把棒棒糖图和热力图结合,用颜色表示第三个维度。比如横轴是渠道,纵轴是月份,每个交叉点画一个棒棒糖,颜色表示转化率。这种图信息密度极高,但可读性会下降,适合给专业读者看,不适合给普通受众。
最后分享一个我在实际项目中总结的小技巧:如果你的数据里有负值,棒棒糖图的线段要从 0 向两边延伸,圆点落在实际数值位置。这时候基线 0 的位置就特别重要,建议用一条浅灰色的参考线标出来,否则读者分不清哪些是正值哪些是负值。这个细节很多教程都不会讲,但实际工作中经常遇到。