在R语言里画条形图(bar chart),可能是大家接触可视化的第一站。但这张看起来人畜无害的图,实际踩坑率远比你想象的高。前阵子帮人排查代码,报错不是重点,最让人头疼的是那句“为什么柱子的高度不是数据里的数值”——这类问题十有八九出在一个地方:你还没搞清楚,手里的数据到底是要做频数统计,还是要把某个数值列直接映射成柱高。
这篇文章不是我抄文档式的罗列参数,而是把R语言条形图这条线从头到尾捋一遍。从 base R 的barplot到 ggplot2 的geom_bar/geom_col,再到排序、标签、中文字体、类别过多这些实际场景里的问题,我都会给出可复现的代码和踩过的坑。适合刚接触R的初学者,也适合写了几年代码但没系统整理过条形图细节的分析师。你不需要一次看完,收藏下来用到哪节翻哪节。
1. 先想清楚:条形图到底在画什么东西
1.1 什么时候该用条形图,什么时候该换图
条形图的核心任务只有一个:用柱子的长度来比较不同类别之间的数量大小。它最擅长回答“谁多谁少”的问题。比如三个渠道的销售额对比、五个部门的工单数量对比、不同商品的销量排名,这些都是条形图的舒适区。
但如果是看趋势,比如某指标连续12个月的变化,我更推荐折线图,因为人眼对“斜率”的感知比对“柱长差”更敏锐。如果是看整体构成占比,比如全年销售额中六个渠道各占多少,可以考虑饼图或堆叠条形图,说实话堆叠条形图往往比饼图更好读,因为类别多了以后饼图的面积很难比较。还有一种是地理数据的分布,比如各省的GDP,虽然本质也是类别比较,但换成地图或气泡图会更直观。
所以第一步不是打开R,而是问自己:我需要让读者做什么?是“看出大小关系”,还是“看出变化方向”,还是“看出结构比例”?答案决定了图型的选择。条形图不是万能钥匙,但它是所有可视化里默认的“对比工具”。
一个容易犯的错是把时间序列数据直接画成条形图。年份作为离散类别时,柱子的分隔感会让“增长趋势”变迟钝,尤其是连续好几年的时候,折线图或面积图的信息效率高得多。反过来,如果你只有三五个连续年份,用柱状对比每个年份的总量也说得通。关键是别死守某一种图。
1.2 频数条形图和数值条形图的本质区别
这是新手最容易翻车的地方。条形图看似简单,但柱子的“高度”到底从哪来,其实有两条完全不同的路。
第一条路是频数统计。你手里是一堆原始记录,比如一份问卷里300个人的答案,你想看看“满意”“一般”“不满意”各有多少人。这时候没有现成的数值列,柱子的高度要靠数出来。在 base R 里,你通常先把数据转成table(),再画barplot(table(...))。在 ggplot2 里,直接用geom_bar(),它默认就是做频数统计的。
第二条路是数值映射。你手里已经有一张带数值列的数据表,比如每个站点对应一个PV数,每个销售区域对应一个销售额。这时候不需要统计次数,而是要“把已有的数值翻译成柱子的高度”。base R 用barplot(数值向量),ggplot2 用geom_col()。
这两种场景的代码长得有点像,但语义完全不同。geom_bar()的默认行为是stat = "count",它只看 x 轴是哪个类别,然后数这个类别出现了多少次,跟数据里其他列一点关系都没有。如果你把一张已经聚合好的表扔给geom_bar(),并且天真地以为它会老老实实画你给的 y 值,那画出来的柱子肯定不对。这个问题我在给同事看代码时几乎每个月都会遇到一次。
1.3 画图前的三个判断题
我自己的习惯是,在写代码之前先过三个判断题,能省掉后面一大半的修改时间。
第一,柱子代表“数量”还是“构成”。数量对比用普通条形图;构成占比用堆叠条形图,特别是要看整体时用百分比堆叠。
第二,柱子是竖着还是横着。类别名短且数量少,竖着没问题;类别名超过6个字,或者类别数量超过10个,果断横过来。后面会详细讲参数。
第三,类别顺序怎么排。这是很多人忽略的一点,却能直接决定这张图“一眼看懂”的程度。默认的字母序、拼音序、输入顺序,几乎永远不是最优解。适当排序会让数据自己说话。
这三个判断题不需要花很多时间,但想清楚之后再画,基本一次成型。
2. base R的barplot:快,但别小看这些参数
2.1 向量、table、矩阵三种输入的不同结果
barplot()是base R自带的函数,不用装任何包,也是很多人写第一张条形图用的函数。它支持三种输入形态,理解清楚会让你避免很多诡异结果。
第一种是数值向量。每个元素画一根柱子,柱高就是元素值。比如:
values <- c(22, 17, 31, 12) barplot(values)如果你在柱子上方什么标签都不加,图上的横轴会显示1, 2, 3, 4这样的下标,没有实际意义。所以实际使用时要加names.arg参数把类别名带上去:
barplot(values, names.arg = c("A组", "B组", "C组", "D组"), col = "steelblue")第二种是table()对象。barplot()遇到table类型时,会自动画出频数图。比如:
set.seed(100) satisfaction <- sample(c("满意", "一般", "不满意"), 300, replace = TRUE) barplot(table(satisfaction))这里柱子的高度不是来自某个数值列,而是来自table()统计出来的次数。注意如果你的原始数据已经是聚合好的,比如“满意150人、一般80人、不满意70人”这种一行一个汇总值的结构,你就不该用table(),直接画向量即可。
第三种是矩阵。矩阵输入时,barplot()会把每一列当成一组,每一行当成一个子类,画成分组条形图或堆叠条形图,由beside参数控制。这块放到2.3节讲。
2.2 标签、方向、宽度这些“救命参数”
我见过很多人的base R条形图画出来“缺胳膊少腿”,多半是这几个参数没配好。
las:控制坐标轴标签的方向,las = 1表示水平、las = 2表示垂直。当类别名比较长时,默认的横排标签会挤成一团,这时用las = 2可以让标签竖排,虽然不够好看,但至少不重叠。更推荐的做法是把图横过来。
horiz:horiz = TRUE一键让柱子变水平。类别名长、数量多时,这是最省心的解法:
barplot(values, names.arg = c("Alpha", "Beta", "Gamma", "Delta"), horiz = TRUE, las = 1, cex.names = 0.8)cex.names:控制类别标签的字号。我一般设到0.75-0.9,尤其是类别数量多的时候,缩小字号比让标签叠在一起好看得多。
ylim:控制y轴范围。很多人忘了给y轴留出足够的顶部空间,导致数值标签、标题和柱子顶端挤在一起。我的习惯是给最大值留出15%到20%的余量:
barplot(values, names.arg = c("A组", "B组", "C组", "D组"), ylim = c(0, max(values) * 1.2))border:默认的柱子有黑色边框,画出来非常“原始感”。设成border = NA会干净很多;如果喜欢细分隔条,可以设成白色,形成类似现代图表的分割效果。
2.3 分组和堆叠就在beside这一个参数里
base R画分组条形图最典型的做法是传入矩阵。假设你有两个年度下三个站点的数据:
mat <- rbind(c(12, 15, 10), c(18, 14, 20)) colnames(mat) <- c("站点A", "站点B", "站点C") rownames(mat) <- c("去年", "今年") barplot(mat, beside = TRUE, col = c("#4D84C8", "#E6A817"), legend.text = rownames(mat), ylim = c(0, 25))beside = TRUE画出来是并排的分组柱子,适合直接比较不同组在同一站点下的差异。如果beside = FALSE,则为堆叠条形图,适合看每组的整体大小以及内部构成。
堆叠图有一个实际体验问题:当其中一个子类数值很小时,它的颜色条会非常窄,图例又很容易跟柱子叠在一起。我的经验是堆叠图使用legend.text = rownames(mat)时,最好把ylim上限留到最大堆叠和的1.15倍,同时用args.legend调整图例位置:
barplot(mat, beside = FALSE, col = c("#4D84C8", "#E6A817"), legend.text = rownames(mat), args.legend = list(x = "topright", inset = 0.05, bty = "n"), ylim = c(0, 45))bty = "n"可以去掉图例边框,这是让图例不那么“远古”的关键。
3. ggplot2的geom_bar与geom_col:一字之差,天壤之别
3.1 geom_bar默认做的是计数,不是画数值
ggplot2是绝大多数R用户的主力绘图包,但它的geom_bar()和geom_col()这两个几何对象,名字只差一个字母,语义完全不一样。
先看geom_bar():
library(ggplot2) df_count <- data.frame(x = c("A", "A", "B", "B", "B", "C")) ggplot(df_count, aes(x = x)) + geom_bar()这段代码画出来的柱子高度是:A为2,B为3,C为1。它是靠stat = "count"自动统计每个x值出现的次数来定的高度。如果你的数据本身是原始明细(一行一条记录),用geom_bar()没问题。
但如果你的数据长这样:
df_value <- data.frame( site = c("站点A", "站点B", "站点C"), pv = c(2350, 4780, 3210) ) ggplot(df_value, aes(x = site, y = pv)) + geom_bar()运行出来会得到一行报错提示你stat="count"不能同时使用y变量,或者更早版本的R会画出三个高度一模一样的柱子。这就是“把聚合表直接扔给geom_bar()”的典型惨案。原因是 ggplot里geom_bar()的默认统计变换是“数数”,它不接受你给的 y 值。要解决只能用geom_col()或geom_bar(stat = "identity")。
3.2 用geom_col直接映射数值列
geom_col()是ggplot2从3.0.0版本开始提供的快捷方式,等价于geom_bar(stat = "identity"),意思是“我给你的y值就是柱子高度,请不要自己统计”。
上面的例子用geom_col()就能正确画出2350、4780、3210三根高度不同的柱子:
ggplot(df_value, aes(x = site, y = pv)) + geom_col(fill = "#2C7FB8", width = 0.7)这里我提一个原则:如果你已经完成了聚合,尽量用geom_col()。它语义清晰,代码不会误导别人。stat = "identity"的写法太隐晦了,团队协作时别人一眼看不出你到底想干吗。
3.3 position参数:dodge、stack、fill
当数据里多了分组维度,就需要处理“多个子类怎么摆放”的问题。ggplot2通过position参数来控制:
df2 <- data.frame( site = rep(c("站点A", "站点B", "站点C"), each = 3), channel = rep(c("自然流量", "广告", "活动"), times = 3), pv = c(1200, 800, 350, 2200, 1500, 1080, 1300, 1200, 710) )三个position分别对应三种构图:
position_dodge(width = 0.9):分组并排,适合子类直接对比。这是最常见的分组条形图。
ggplot(df2, aes(x = site, y = pv, fill = channel)) + geom_col(position = position_dodge(width = 0.9))position_stack()(position = "stack"):堆叠,柱子的总高度是各组总和,适合看整体量级,再看内部构成。
position_fill()(position = "fill"):百分比堆叠。所有柱子高度一样,都是100%,每根柱子内部反映各子类占比,适合对比不同类别的结构差异。此时y轴显示的是百分比,需要配合labels = scales::percent_format()才好看:
ggplot(df2, aes(x = site, y = pv, fill = channel)) + geom_col(position = "fill") + scale_y_continuous(labels = scales::percent_format())这三种选择,本质上对应你真正想回答的问题:比总量?看差异?还是看结构?想清楚再选 position,图才有说服力。
4. 让条形图信息量翻倍的关键:排序和因子水平
4.1 默认顺序为什么经常是“最没用的顺序”
新手上路时最容易忽略的就是排序。ggplot2在处理离散x轴时,默认按因子水平顺序排列;如果你的x列是字符型,则按字母顺序;如果是中文,则按拼音或编码顺序排列。问题是这种顺序几乎永远没法突出“谁大谁小”的信息。
举个例子,五个站点的PV分别是4780、3210、2350、5560、1800,如果按站点名拼音排,柱子高低交错,读者要自己逐个去比,图表的价值就大打折扣。条形图的第一原则不是“好看”,而是让读者在一秒内看出大小排名。
所以我画条形图的固定动作永远是:在进入ggplot之前先把类别按数值排序,或者直接在aes()里写reorder。
4.2 reorder和fct_reorder的实用姿势
reorder()是base R的函数,fct_reorder()来自forcats包,二者目的相同:按另一个数值列的大小来重排因子水平。
df_value$site <- reorder(df_value$site, df_value$pv)注意reorder()默认是升序排列,数值最小的因子水平在第一位。画出来就是最低的在左边,最高的在最右边。如果你想要“最高在左边”的降序排列,有两个办法:
df_value$site <- reorder(df_value$site, -df_value$pv)或者在使用forcats时用.desc = TRUE:
library(forcats) ggplot(df_value, aes(x = fct_reorder(site, pv, .desc = TRUE), y = pv)) + geom_col()我个人的习惯是把排序写在数据准备阶段,而不是画图代码里。因为后续还要在柱子上加标签、生成表格,数据本身的顺序统一了,后面所有操作都不容易乱。
4.3 坐标轴顺序:limits和breaks的分工
有时候不想按数值排,而想按业务顺序排,比如“售前→售中→售后”这种有流程含义的顺序。这时有两种改法,但效果不完全一样。
第一种是直接改因子水平:
df_value$site <- factor(df_value$site, levels = c("站点A", "站点C", "站点B"))第二种是在可视化阶段用scale_x_discrete(limits = ...):
ggplot(df_value, aes(x = site, y = pv)) + geom_col() + scale_x_discrete(limits = c("站点A", "站点C", "站点B"))两者的区别在于:改因子水平会全局影响所有用到这个变量的图和数据操作;limits只影响当前图。我建议能改数据就改数据。因为当你同时用fill时,图例的顺序也由因子水平决定,只在limits里改x轴顺序,图例顺序可能依然按原因子水平排,容易造成视觉上的割裂。
5. 标签、颜色、中文字体:细节决定一张图能不能直接放进报告
5.1 在柱子上放数字标签的正确写法
画图本身不难,难的是让这张图可以直接放进PPT或报告里。第一件事就是加数值标签。单个序列很简单:
ggplot(df_value, aes(x = reorder(site, pv), y = pv)) + geom_col(fill = "#2C7FB8") + geom_text(aes(label = pv), vjust = -0.5, size = 3.5)vjust = -0.5表示标签放在柱子顶端上方;如果数值有上千位,可以用format(pv, big.mark = ",")加上千分位分隔符,避免显示一长串裸数字。
分组条形图的情况要麻烦一点。geom_text必须使用和柱子相同的position_dodge,否则所有标签会堆在x轴中点位置:
ggplot(df2, aes(x = site, y = pv, fill = channel)) + geom_col(position = position_dodge(width = 0.9), width = 0.8) + geom_text(aes(label = pv), position = position_dodge(width = 0.9), vjust = -0.4, size = 3)百分比堆叠图的标签逻辑又不一样。此时柱子被拉伸到相同高度,y值本身没有直接意义,需要手动计算占比再映射:
library(dplyr) df2_pct <- df2 %>% group_by(site) %>% mutate(pct = pv / sum(pv)) %>% ungroup() ggplot(df2_pct, aes(x = site, y = pv, fill = channel)) + geom_col(position = "fill") + geom_text(aes(label = scales::percent(pct)), position = position_fill(vjust = 0.5), size = 3)这里position_fill(vjust = 0.5)帮你把文字定在每段堆叠区间的中间,不需要自己去算坐标。但要注意,当某个占比太小时(比如不到5%),文字会挤在一起或溢出柱子范围。这时候宁可不要标签,也别硬塞。
5.2 颜色映射与图例顺序控制
颜色映射用scale_fill_manual或scale_fill_brewer。条形图的配色原则很简单:别用太多颜色。我觉得一组柱子里超过5种颜色就不太可控了;如果只是单序列,一个主色加一个强调色足够了。
图例的顺序是由fill变量的因子水平决定的,不是由颜色顺序决定的。很多人手动改了图例文字、改了颜色,但图例顺序依然是字母序,怎么看怎么别扭。最直接的办法是提前把该列转成factor并指定levels:
df2$channel <- factor(df2$channel, levels = c("自然流量", "广告", "活动"))这一步能同时控制图例顺序和堆叠顺序。不要在画图时临时用sort或rev去调,那只会制造新的混乱。
5.3 中文字体和长标签的处理
在Windows下用R画图,中文标签经常变成方块。这个问题的根治方案是用showtext包:
library(showtext) font_add("heiti", "simhei.ttf") showtext_auto()之后正常画图,中文就能渲染出来,不需要在ggplot2里一个个设family。但要注意,showtext_auto()必须在使用设备前调用,否则可能不起作用。导出图片时也一样,所有字体相关设置完成后,再跑ggsave()。
长标签的处理是另一个细节。如果站点名有十几个字,可以用stringr::str_wrap手动换行:
df_value$site_short <- stringr::str_wrap(df_value$site, width = 4)width = 4表示每隔4个字符换行。这个操作我会放到数据清洗阶段,不要在aes()里临时处理,否则标签和图例文字的对应关系容易乱。
5.4 y轴是否必须从0开始
条形图有一条视觉铁律:柱子长度的比例必须真实反映数值比例,所以y轴通常应该从0开始。从非0起点截断会放大差异,造成误导。比如两个柱子分别代表100和110,如果y轴从90开始,看起来A只有B的一半,这种视觉欺诈是报告里很敏感的问题。
但也有例外。当所有数值都非常接近时,比如都在90到110之间,0起点的柱子高度差异几乎看不出,这时候要么接受“差异不明显”的结论,要么考虑换图。我个人的做法是:如果差异很小但业务上重要,就在0起点柱子上标注数值,同时用文字强调差异,而不是截断y轴。如果确实想放一个截断轴,也必须用波浪符号或其他方式明确标注坐标轴被截断,并且不隐藏任何数据点。
相对更稳妥的方案是改用点图或克利夫兰点图,它不依赖柱子长度,视觉上更诚实。
6. 实测翻车现场:类别太多、标签重叠、factor偷改顺序
6.1 类别超过20个怎么办
条形图对类别数量非常敏感。当类别超过20个时,竖着的柱子会变成“千根面条”,横过来也只是一堆细线,柱子之间的差异基本看不清。这个问题不是改参数能解决的,而是图形类型的选择问题。
我常用的替代方案是棒棒糖图(lollipop chart),本质上是散点加线段。它的视觉噪音更小,信息密度更高:
df_many <- data.frame( item = paste0("商品", 1:30), value = sample(50:500, 30, replace = TRUE) ) ggplot(df_many, aes(x = reorder(item, value), y = value)) + geom_segment(aes(xend = item, yend = 0), color = "grey60") + geom_point(size = 2.5, color = "#2C7FB8") + coord_flip()geom_segment画一条从0到数值的线段,geom_point在数值顶端画点,再加上coord_flip()把图横过来。这样30个类别也不会重叠,而且因为点是独立的,比较起来更清晰。
如果类别数量超过50,棒棒糖图也吃力了,那就考虑热力图、排序条形图只显示Top N,或者用分面把数据拆成多张小图。
6.2 标签重叠和截断的排查思路
标签重叠是条形图最常被问到的问题之一。我的排查路径是这样的:
第一步,看是不是标签字号太大。size = 3和size = 4在屏幕上相差不大,但导出300dpi图片时差异会被放大。先把字号降下来再说。
第二步,看是不是方向问题。标签多时可以用angle = 45+hjust = 1让文字斜着排,但斜体标签阅读体验不好,能横排就别斜排。
第三步,考虑是否要给标签“分级”。如果30个柱子都想标,再大的图也会挤。我的做法是只标Top10或Top15,其余不标,数据表格里反正有完整值:
df_many <- df_many %>% arrange(desc(value)) %>% mutate(label = if_else(row_number() <= 10, as.character(value), "")) ggplot(df_many, aes(x = reorder(item, value), y = value)) + geom_col() + geom_text(aes(label = label), hjust = -0.2, size = 3)这种“只标头部标签”的思路在业务报告里非常实用,既保证了重点突出,也避免了标签大战。
6.3 factor隐式转换闯的祸
这个坑我前后踩过两三次,值得单独拎出来说。很多人知道as.numeric(factor)会把因子转换成数字,但不知道它转换的是水平的序号,而不是水平本身对应的数值。
f <- factor(c("10", "20", "30")) as.numeric(f)你期望得到10, 20, 30,实际得到的却是1, 2, 3。这在做分组排序和数值计算时会引发灾难性的错位。比如你用as.numeric(站点名)去做数值标签,站点名会变成1、2、3,看起来像某种编号,实际毫无意义。
更隐蔽的问题是,当你同时用reorder()和factor(levels = ...)时,后者的手动顺序会被reorder()覆盖。如果你发现自定义的levels顺序画出来完全没生效,优先检查代码里是不是还有别的reorder()或factor()在同一个变量上操作。我现在的习惯是:排序只用一种方案,要么全交给levels,要么全交给reorder,不混着来。
这个问题也提醒我,做数据清洗时要留意列类型。很多时候一个看起来正常的数字列,被读入时可能已经被识别成因子或字符型,后续所有排序、求和都会走样。
6.4 导出图片的尺寸与字体一致性问题
绝大多数人画图是在RStudio窗口里看的,觉得挺好,一导出就废。原因通常是尺寸和字体没有一起考虑。
ggsave()默认的尺寸是屏幕窗口大小,直接导出到PPT里会显得图例、标签、都偏小。我常用的配置是:
ggsave("bar_chart.png", width = 8, height = 6, dpi = 300, bg = "white")width = 8和height = 6的单位是英寸,宽高比大概4:3。竖排柱子适合稍宽一点的画布,横排条形图适合更高一些的画布,这个要根据内容调整。导出后如果发现文字偏小,不要盲目调字号,先确认画布尺寸对不对。同一张图在8英寸宽和12英寸宽的情况下,字号感受完全不同。
另一个坑是字体一致性。同一份代码在Windows上导出没问题,换台机器或放到Linux服务器上跑,中文字体可能变方块。用showtext包能缓解大部分跨平台问题,但它依赖系统里确实装了对应字体。最稳妥的办法是项目里统一指定一个字体路径,比如把simhei.ttf放在项目根目录,用绝对路径加载,避免依赖机器本身。
7. 我手边常用的最小可复现模板(经验向收尾)
如果你不想每次从头调,下面这个模板是我现在画条形图的默认起点,直接替换数据就能用:
library(ggplot2) library(forcats) library(dplyr) # 数据准备,已完成聚合,列名统一为 category / value / group df_plot <- df_summary %>% arrange(desc(value)) %>% mutate(category = fct_reorder(category, value, .desc = TRUE)) # 基础图 p <- ggplot(df_plot, aes(x = category, y = value)) + geom_col(fill = "#2C7FB8", width = 0.7) + geom_text(aes(label = format(value, big.mark = ",")), vjust = -0.5, size = 3.2) + labs( title = "标题", subtitle = "副标题", x = NULL, y = NULL ) + theme_minimal(base_size = 12) + theme(axis.text.x = element_text(angle = 45, hjust = 1)) # 导出 ggsave("output/bar_chart.png", p, width = 8, height = 6, dpi = 300, bg = "white")如果是有分组的数据,把geom_col换成geom_col(position = position_dodge(width = 0.9)),再配合fill = group映射即可。这个模板虽然简单,但已经包含了排序、标签、导出尺寸这三个最常见的坑。
最后分享一个我自己的体会:条形图真的不需要太多花活。颜色不要超过三种,标签只保留必要信息,类别顺序一定按数值排,y轴老老实实从0开始。做到这四点,你的图就已经超过了大部分“能用但不好看”的默认输出。可视化好不好,说到底不是工具问题,而是你对数据关系有没有想清楚。R语言条形图这条路,走到后面你会发现,最重要的反而不是代码,而是“你想让读者一眼看到什么”。