什么时候用它
这是最基本的那张图:两个数值变量,一个点是一次观测。相关系数、回归斜率、以及一切"两个量有关系"的说法,都必须先在这张图上站得住。
correlation_heatmap 那一页反复说"回到散点图确认",说的就是这里。相关系数把一段关系压成一个数,而压缩必然丢东西——丢掉的正是形状:是直是弯、有没有离群点在主导、是不是几个分组各自成团拼出来的假趋势。这些在热图上全都看不见,在这张图上一眼就有。
怎么读
- 先看形状,别急着看线。 点云是沿一条直线散开,还是拐了个弯、或者分成几团?拟合线画在任何点云上都会出来一条,它不负责告诉你该不该画。
- 看离散程度。 点贴着线还是甩得很开。
r 高只说明贴得紧,不说明斜率大;斜率大也不说明贴得紧。这是两件事。
- 找孤立点。 一个远离点云的观测能显著改变斜率和
r,尤其在 x 方向上远的那种(杠杆点)。看到就单独查一下它是什么。
- 看置信带宽度。 两端变宽是正常的——那里数据少。带子宽到能塞进一条水平线,就说明"有没有趋势"这件事本身都还没定。
- 再问一次有没有分组。 这是最容易跳过的一步,也是最容易翻车的一步:整体一条趋势,拆开之后可能完全是另一回事。
常见陷阱
- 整体斜率不等于组内斜率。 下面方法 B 就是例子:
mpg ~ wt 整体斜率 −5.34,但 6 缸组是 −2.78、8 缸组是 −2.19,只有整体的一半上下。多出来的那一半来自组间偏移——8 缸车又重又费油,两个方向同时偏,把整条拟合线拽陡了。极端情况下组内斜率会和整体反号,那就是辛普森悖论。
- 别在小组里认真读斜率。 上面那三组里 6 缸只有 7 辆车。分组拟合最容易出的问题不是算错,是把 n=7 的斜率当成结论。
geom_smooth() 默认不是直线。 不写 method 时,n < 1000 走的是 loess。想要直线必须显式写 method = "lm",否则你以为在看线性关系,其实在看一条会跟着局部数据扭的曲线。
- 别外推。 拟合线只在数据覆盖的 x 范围内有依据。
xlim() 会把点连同拟合一起裁掉再重算,要放大看局部得用 coord_cartesian()。
- 点重叠了就不是散点图了。 几百个点以上会糊成一片,那时该上
alpha、geom_hex() 或者二维密度——散点图的前提是数得清。
配色
分组变量 cyl 是 4 / 6 / 8 缸,这是有序的分类,而且图上没有任何别的通道在表达这个顺序(x 轴是车重,不是缸数)。所以这里用单色渐深的三档,缸数越多颜色越深——顺序读得出来,而且黑白打印后深浅仍然能排。
这也是这一条没有引用本站配色的原因:站内那六套是定性或发散型,定性色的整个设计目标就是让人分不出先后,正好和这里要的相反。配色是按变量类型选的,不是按好不好看选的。
拟合线用比点更深的同色系,让线压在点云上面还能认出属于哪一组。
配方
数据准备
mtcars 直接能用,只要把 cyl 转成因子——不转的话它是数值 4/6/8,会被当成连续量映射成渐变色标,而它其实是三个组。
library(ggplot2)
library(ggpubr)
d <- mtcars
d$cyl <- factor(d$cyl) # 不转的话图例会变成一根连续色条
cyl_cols <- c("4" = "#74A9CF", "6" = "#2B8CBE", "8" = "#045A8D")
方法 A · 一条拟合线
#| fig: basic
#| fig-width: 7
#| fig-height: 5
ggplot(d, aes(x = wt, y = mpg)) +
geom_point(size = 2.6, alpha = 0.85, color = "#2B8CBE") +
geom_smooth(
method = "lm", # 必须显式写 —— 默认在 n < 1000 时是 loess,会画出曲线
formula = y ~ x, # 显式给公式,否则每次画图都提示一行 message
se = TRUE, # 置信带:它宽不宽比线本身斜不斜更值得看
color = "#045A8D", fill = "#045A8D", alpha = 0.15, linewidth = 0.9
) +
# r 和 p 直接标在图上,省得读者去别处找
stat_cor(method = "pearson", label.x = 3.8, label.y = 33, size = 4.2) +
labs(
x = "Weight (1000 lbs)", y = "Miles per gallon",
title = "mpg vs wt — mtcars"
) +
theme_pubr(base_size = 12)
scatter_fit-basic
点云沿一条直线散得挺紧,r 也很高。到这里为止,"车越重越费油"这个结论看着毫无问题。
方法 B · 按分组拆开
同一份数据,只多映射一个 color = cyl,结论就要改口了。
#| fig: grouped
#| fig-width: 7.5
#| fig-height: 5
ggplot(d, aes(x = wt, y = mpg, color = cyl)) +
geom_point(size = 2.6, alpha = 0.9) +
geom_smooth(
method = "lm", formula = y ~ x,
se = FALSE, # 组内样本少,置信带会宽到互相盖住,反而看不清三条线
linewidth = 0.9
) +
# 整体那条虚黑线:group = 1 覆盖掉分组映射,让它跨所有点拟合一次
geom_smooth(
aes(group = 1), method = "lm", formula = y ~ x,
se = FALSE, color = "grey30", linetype = "dashed", linewidth = 0.8
) +
scale_color_manual(values = cyl_cols, name = "Cylinders") +
labs(
x = "Weight (1000 lbs)", y = "Miles per gallon",
title = "Same data, split by cylinder count",
subtitle = "Dashed line: pooled fit across all 32 cars"
) +
theme_pubr(base_size = 12, legend = "right")
scatter_fit-grouped
三条实线明显比那条虚线平。整体斜率 −5.34,组内是 −5.65 / −2.78 / −2.19——6 缸和 8 缸只有整体的一半上下。差出来的部分不是"重量更伤油耗",而是三个组本身就分别占着图的不同角落:8 缸车集中在右下(重、费油),4 缸车集中在左上(轻、省油)。拟合线穿过这三团的连线,斜率自然比任何一团内部都陡。
两种方法怎么选
- 只有两个变量就是 A。 没有分组变量可拆的时候,A 就是完整答案。
- 手上有分组变量,就一定要画一次 B。 哪怕最后放进正文的是 A——先看一眼分组会不会改变结论,这一步的成本是一行
color =,不看的代价是整段结论可能是组间偏移伪装出来的。
- B 不一定要进正文。 组数多、每组样本少的时候,B 会是一团互相穿插的线,读起来比 A 差得多。那种情况下正确的做法是:自己画 B 确认过,正文放 A,并在文字里交代分组之后趋势是否一致。