用R语言里的ggplot包在同一张图上画两组折线图或者散点图,是我这几年做数据分析时被问得最多的一件事。乍一听特别基础,真上手却有一堆细节:数据到底该怎么组织、颜色图例怎么分开、点太多糊成一团怎么办、为什么我只想画两条线却报错。这篇文章就围绕这个真实需求展开,用模拟数据一步步演示两组折线图、两组散点图,以及折线叠散点的常见组合画法,把背后的ggplot映射逻辑和实际踩过的坑都讲清楚。不管你是刚接触R的初学者,还是需要拿图去汇报的进阶用户,只要想在同一个画布上比较两组的趋势或分布,这篇都能当参考。
1. 先搞懂ggplot的绘图思维:宽表、长表与映射
1.1 分组问题的本质是“美学映射”
ggplot2的核心思想不是“画图”,而是把数据框里的每一列“映射”到图形的某个属性上,比如x轴、y轴、颜色、形状、线型、透明度。想在同一个图上表示两组数据,本质就是给ggplot一个信号:哪些点属于第一组,哪些点属于第二组。这个信号通过aes()传入,最常见的就是color = group或linetype = group。
很多人会本能地写两次geom_line,分别画每组的数据,然后发现图例不好合并、颜色不好统一。其实更稳的思路是用一个geom,把组别变量映射到视觉属性上,剩下的事情ggplot会自动完成:自动分组、自动配颜色、自动生成图例。理解了这一层,后面所有看似复杂的问题都会变得简单。
1.2 宽表和长表:ggplot偏爱“长表”
R里常见两种数据结构。一种叫宽表,每一行是一个时间点,A组和B组各占一列;另一种叫长表,把A组和B组堆叠起来,一列是组别,一列是数值。ggplot做多组对比时,更偏好长表。
宽表的典型长这样:
df_wide <- data.frame( week = 1:12, A = c(3.2, 3.6, 4.1, 4.5, 5.0, 5.4, 5.9, 6.3, 6.8, 7.1, 7.6, 8.0), B = c(5.2, 5.1, 4.9, 5.2, 5.5, 5.0, 4.7, 4.9, 4.6, 4.3, 4.1, 3.9) )长表则要把A、B两列“溶化”成一列组别、一列数值,推荐用tidyr包里的pivot_longer:
library(tidyr) df_long <- pivot_longer( df_wide, cols = c(A, B), names_to = "group", values_to = "value" )转换之后,df_long就变成了week、group、value三列。这个时候画图,aes(x = week, y = value, color = group)一句就能拆开两组。如果坚持用宽表画,也不是不行,但要分两次geom_line,还得手动控制图例,过程更别扭。
宽表还是长表,看似只是数据组织的差异,实际直接影响绘图代码的复杂度。我的经验是:只要涉及多组比较,先转成长表再画,能少踩一半的坑。
1.3 构造一份可复现的模拟数据
为了方便演示,这里我直接构造一份长格式模拟数据,模拟一个时间序列实验:对照组和处理组各测了12周,有一个略微不同的趋势,再加一点随机波动。用set.seed保证每次运行结果一致,方便复现:
library(ggplot2) set.seed(42) df <- data.frame( week = rep(1:12, 2), group = rep(c("对照组", "处理组"), each = 12), value = c( 3 + 0.4 * (1:12) + rnorm(12, 0, 0.3), 5 - 0.2 * (1:12) + rnorm(12, 0, 0.35) ) ) head(df)这份数据结构简单,正好覆盖本文所有场景:折线图、散点图、分组映射、图例调整。后面所有代码都基于这个df,读者直接复制就能跑,再换成自己的数据即可。
2. 两组折线图:从一行代码到定制化输出
2.1 基础画法:一个geom_line加color映射
最基础的两组折线图,只需要一行核心代码:
p <- ggplot(df, aes(x = week, y = value, color = group)) + geom_line() p因为group是字符型变量,ggplot会把它当作离散变量处理,自动为两组分配不同颜色,并生成一个图例。如果想要进一步区分两组,可以把组别同时映射到线型,这在黑白打印场景下尤其有用:
ggplot(df, aes(x = week, y = value, color = group, linetype = group)) + geom_line(size = 1)注意这里linetype = group写在了全局aes()里,所以图例是统一的一个,既显示颜色也显示线型。很多人习惯在geom_line内部额外写linetype = group,效果一样,但后续要通过guides调整图例时,容易各自独立。
2.2 自定义颜色、线型和图例名称
ggplot默认配色是可读性不错的,但到了正式汇报场景,通常需要固定成品牌色或约定色。用scale_color_manual手动指定:
p <- ggplot(df, aes(x = week, y = value, color = group)) + geom_line(aes(linetype = group), size = 1.2) + scale_color_manual(values = c("对照组" = "#4E79A7", "处理组" = "#E15759")) + labs( color = "实验分组", linetype = "实验分组", x = "周数", y = "观测值" ) plabs里同时写了color和linetype的标题,确保两个图例名称一致。不过因为color和linetype被映射到了同一个离散变量,ggplot通常会自动合并成一个图例,这时只需在labs里写一个color即可。如果想统一修改,也可以写:
guides(color = guide_legend(title = "实验分组"), linetype = guide_legend(title = "实验分组"))两条设置都写的好处是,万一ggplot没有自动合并,图例也不会出现两个不同标题。
2.3 折线加数据点:信息量更完整
折线图只反映趋势,数据点位置才是原始证据。样本量不大时,强烈建议在折线图基础上叠加geom_point。一个很实用的小技巧是同时映射shape:
ggplot(df, aes(x = week, y = value, color = group, shape = group)) + geom_line(aes(linetype = group), size = 1) + geom_point(size = 3, alpha = 0.8) + scale_color_manual(values = c("对照组" = "#4E79A7", "处理组" = "#E15759"))这样颜色、线型、形状三者都携带分组信息,看图的人即使色弱,也能通过形状或线型区分。alpha设到0.8是为了让点有点透气的质感,又不至于太淡。如果点被线遮挡,可以把geom_point放在geom_line之后,让它画在最上层。
2.4 数据缺失时折线会断,怎么办
两组数据如果观测时间点不完全一致,比如对照组测了12周,处理组中间漏了3周,折线图会出现断裂。这种情况最简单也是最重要的原则是:不要为了连线而连线。如果x轴是时间点,缺失本身就是信息缺失,强行用线性插值补全反而可能误导人。
建议做法有三种:
- 数据量充足时,先用
df %>% tidyr::complete(group, week)补成完整网格,缺失值会变成NA,折线自动断开。 - 如果希望连线平滑,可以用zoo包里的na.approx做线性插值,但必须清楚这是估计值,不是实测值。
- 直接在图上只画散点,不画线,让读者看到缺失点的位置。
实际场景里,需要按周对齐、画线,但个别点没采集到,我会保留NA并用完整网格画线,同时叠加一圈散点标记实测位置。
3. 两组散点图:颜色、形状与点重叠的处理
3.1 最直观的散点图:color加shape同时映射
散点图和折线图的逻辑一样,把组别映射给color或shape即可。散点图里shape的作用尤其重要,因为点的颜色在灰度打印或色弱环境下不易区分,形状却不会受影响。
ggplot(df, aes(x = week, y = value, color = group, shape = group)) + geom_point(size = 3) + scale_color_manual(values = c("对照组" = "#4E79A7", "处理组" = "#E15759"))运行这段代码后,两组点会分别有不同颜色和不同形状,图例里会同时显示颜色和形状信息。默认情况下,ggplot会把color和shape的图例合并成一个,因为两者映射自同一个变量。这种合并通常是好事,图例更简洁。
如果想拆成两个图例,比如希望shape的图例叫做“组别形状”,color的图例叫做“组别颜色”,可以分别指定guides。但多数情况下,统一图例更符合汇报习惯,我不太建议拆开。
3.2 折线加散点:趋势和原始数据同时出现
折线图和散点图不是互斥的。在同一张图上,先画半透明的折线反映趋势,再画实心点标出原始观测值,是两张图合二为一的常见操作。代码非常简单:
ggplot(df, aes(x = week, y = value, color = group, shape = group)) + geom_line(aes(linetype = group), alpha = 0.5, size = 1) + geom_point(size = 3) + scale_color_manual(values = c("对照组" = "#4E79A7", "处理组" = "#E15759"))这里的关键在于alpha=0.5。把线设为半透明,点保持不透明,视觉上点会更突出,线则成为辅助参考。如果反过来想突出趋势、弱化点,就把点的alpha调低。图层的顺序和透明度其实就是你的视觉引导逻辑,想好重点在哪里,再决定参数。
3.3 点重叠太严重,用透明度和抖动来救
两组散点如果数值接近,点会大面积重叠,看起来只是一个实心色块。比如对照组和处理组的值都在4.8到5.2之间上下波动,叠加后根本分不清哪点多。面对这种重叠,我有三个常用手段。
第一个是alpha透明度。把点设为alpha = 0.5,重叠区域颜色会更深,不同组的密度分布一下子就能看出来。第二个是position_jitter,给点增加非常小的随机抖动,避免完全重合。注意,抖动只适合离散坐标或展示分布的场景;如果x轴是严格时间轴,抖动会改变点的位置,容易被人质疑。第三个是改用箱线图或小提琴图,把分布画出来,彻底避开散点重叠问题。这三招可以组合,效果最好的是alpha加轻微抖动。
3.4 用图层顺序强调重点组
两组数据一起画图,并不总是要“一视同仁”。如果处理组才是核心,对照组只是参考,可以让对照组变灰、处理组变亮,或者调整图层顺序,让处理组点盖在对照组上面。具体实现思路是在scale_color_manual里把对照组设为灰色,同时把处理组设为高饱和色:
ggplot(df, aes(x = week, y = value, color = group)) + geom_point(size = 3, alpha = 0.9) + scale_color_manual(values = c("对照组" = "#BDC0C4", "处理组" = "#D7263D"))这种“视觉分层”的做法非常适用于项目汇报,能让看的人第一眼就抓住重点。不要小看这个细节,一张图读完需要十秒还是一秒,往往取决于重点组有没有被视觉强调出来。
4. 从两组到多组:数据叠加、分面与双y轴的话题
4.1 手动叠加另一组数据:data参数的用法
有时两组数据不在同一个数据框里,甚至行数都不一致。这种情况下不需要强行合并数据,可以直接用data参数叠加第二个图层:
df_extra <- data.frame( week = 3:10, value = seq(5.5, 4.8, length.out = 8), group = "外部参考线" ) ggplot(df, aes(x = week, y = value, color = group)) + geom_line() + geom_line(data = df_extra, aes(linetype = group)) + scale_color_manual(values = c("对照组" = "#4E79A7", "处理组" = "#E15759", "外部参考线" = "black"))如果第二组本来就带group列,那么它的映射会自然出现在图例里。如果df_extra没有group列,可以用一个常数映射去制造图例项,比如aes(color = "外部参考线"),这属于ggplot里非常实用的小技巧。加在这一层之后,图例里会多出一个外部参考线的组别,颜色和线型都能单独控制。
4.2 分面:另一种“同图对比”的替代方案
不是说所有多组数据都适合硬塞在同一张图里。当组数超过3组,或者各组的量纲完全不同时,同一张图里的折线很容易互相缠绕。这时候用facet_wrap按组别拆成小图,反而更清楚:
ggplot(df, aes(x = week, y = value, color = group)) + geom_line() + facet_wrap(~ group)分面的好处是每张子图共用x、y轴刻度,方便比较绝对数值和变化趋势。同一张图两条线适合展示“两条线的交叉点和差异”,分面适合展示“每一组的自身趋势”。我一般先问自己:是想要读者对比两组大小,还是想看各组走势?前者用叠加,后者用分面。
4.3 两组数据量纲不同,能不能用双y轴
最容易被问到的问题之一:一组数量级是几十,另一组是几万,怎么画在同一张图?很多人的第一反应是双y轴,左侧一个刻度、右侧一个刻度。ggplot的scale_y_continuous也可以配合sec_axis做,代码能实现,但我不推荐。
双y轴的坑在于,它会通过调整两个轴的缩放比例,让两条线看起来有某种“视觉关系”,而这种关系是人为设定的。只要改一个轴的比例,两条线的走势对比就可能完全变味。如果你在汇报时遇到懂行的听众,双y轴几乎一定会被挑战。更稳妥的办法有两种:一是把两个变量都转换成标准化指标,比如变化率、指数值,放在同一个y轴上比较;二是用两个分面图上下排列,分别画各自的量纲。后者信息量最大,也最不容易误导人。
5. 高频报错与排查技巧:我实际踩过的坑
5.1 Aesthetics must be either length 1 or the same as the data
这是ggplot新手最容易遇到的报错,通常出现在aes()里写了长度不对的向量。比如数据框有200行,却写color = c("A", "B"),ggplot不知道这两个值该分配给哪些行。正确做法是把组别信息放进数据框本身,再在aes里引用列名。偶尔也有其他原因,比如aes里误用了全局向量color = group_vector,而不是color = group_column。排查思路就是检查aes里每一个变量是不是数据框里的列名,或者长度为1的常量。
5.2 图例丢失,或者出现一条渐变色条
图例丢失最常见的原因是把color写在了geom_line外部而不是aes里。比如geom_line(color = group)这种写法,R会把group当作一个固定颜色值,而不是映射关系,图例自然不出现。正确写是geom_line(aes(color = group))。另一个常见问题是,color映射到了一个连续数值变量,比如color = value,这时候ggplot生成的是渐变色条,而不是分组图例。解决方法是把该变量转成因子,或者在映射时使用color = as.factor(group)。
5.3 x轴顺序乱了:字符型还是因子型
很多人画月度数据时,图上线段的顺序是乱的,比如一月、十一月、十二月排在二月前面。原因是x轴被当成字符型,ggplot按字母顺序排序了。解决方式有两个:
df$month <- factor(df$month, levels = c("一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"))如果数据本身是日期,用as.Date处理更自然:
df$date <- as.Date(df$date, format = "%Y-%m-%d")因子顺序问题常常藏得很深,尤其是导入Excel数据后,看似时间列其实以文本格式存储。我建议做图前先用str()或summary()检查列类型,再决定是否转因子。
5.4 中文乱码与导出图片模糊
ggplot默认绘图设备对中文支持不稳定,经常在出图时出现方块字。我的最佳实践是用showtext包加载字体,并把自动模式打开:
library(showtext) showtext_auto()然后在theme里指定family为已加载的字体名,比如theme(text = element_text(family = "SimHei"))。如果是在RStudio里运行,每一步都能即时预览,很方便。导出图片的清晰度问题,多半是因为ggsave缺省尺寸太小、dpi太低。我一般用:
ggsave("两组折线图.png", p, width = 7, height = 4.5, dpi = 300)这样出来的PNG在PPT或汇报文档里放大也不会发虚。SVG格式也类似,如果是网页端展示,SVG可能更合适。
5.5 图例标题和标签改来改去都没用的疑惑
图例标题修改看似简单,实际很容易迷失。当color、linetype、shape分别映射到同一个组别变量时,统一图例只需改一个;当它们映射自不同变量时,就得一个个用labs或guides设置。最让我头疼的情况是同时映射了color和size,结果出现两个图例,一个标题叫“组别”,另一个叫“组别Size”。解法还是用labs分别指定,或者干脆不要映射多余属性。我个人的原则是:一个变量最多映射两个视觉属性,否则图例会膨胀到难以阅读。
6. 我的个人实操心得
画图技术上不难,难的是每次都站在看图的读者角度反问一句:这张图让我十秒内看懂了吗?我会定期检查自己的图,颜色是否考虑过色弱读者、线型是否在黑白打印后仍能区分、x轴的顺序是不是真的按时间排的、点有没有重叠到看不清密度。这些小问题积累起来,才是图从“能看”变成“好用”的关键。
另外一个很实用的习惯是,任何正式汇报用的图,我都会先保存一个低分辨率的快速预览版扫一眼,再输出高清版。因为很多字体、图例重叠问题,在RStudio预览里不明显,放大到海报尺寸就会暴露。先小后大,能省很多往返调整的时间。
最后再分享一个我自己的小技巧:在做多组对比图时,把组别变量统一命名为group,把所有数值统一命名为value,这样代码几乎永远不用改aes,只要换数据源就行。长格式加规范命名,是我这几年用ggplot最省心的工作流。