Folioevanzhou.org
← 返回 Tessera
R2 张图2026-08-04

scatter_fit

Two numeric variables as points, with a fitted line and its confidence band laid over the cloud.

示例数据
mtcars
配色
three_body
语言
R
成图预览另有 1 张在配方里
scatter_fit

Introduction

这是最基本的那张图:两个数值变量,一个点是一次观测。相关系数、回归斜率、以及一切"两个量有关系"的说法,都必须先在这张图上站得住。

correlation_heatmap 那一页反复说"回到散点图确认",说的就是这里。相关系数把一段关系压成一个数,而压缩必然丢东西——丢掉的正是形状:是直是弯、有没有离群点在主导、是不是几个分组各自成团拼出来的假趋势。这些在热图上全都看不见,在这张图上一眼就有。

读的时候按这个顺序:

  1. 先看形状,别急着看线。 点云是沿一条直线散开,还是拐了个弯、或者分成几团?拟合线画在任何点云上都会出来一条,它不负责告诉你该不该画。
  2. 看离散程度。 点贴着线还是甩得很开。r 高只说明贴得紧,不说明斜率大;斜率大也不说明贴得紧。这是两件事。
  3. 找孤立点。 一个远离点云的观测能显著改变斜率和 r,尤其在 x 方向上远的那种(杠杆点)。看到就单独查一下它是什么。
  4. 看置信带宽度。 两端变宽是正常的——那里数据少。带子宽到能塞进一条水平线,就说明"有没有趋势"这件事本身都还没定。
  5. 再问一次有没有分组。 这是最容易跳过、也最容易翻车的一步:整体一条趋势,拆开之后可能完全是另一回事。

Example Data

散点图要的输入是:2 个数值变量,同一批观测,一行一个观测;要拆分组再加 1 个分类变量。两个变量必须来自同一行,否则连成对都谈不上。

mtcars 是 R 内置数据,32 辆车,这里看车重(wt)和油耗(mpg)。它已经能直接用,只要把 cyl 转成因子。

library(ggplot2)
library(ggpubr)
library(biopalette)

# mtcars 随 R 一起装好,不用下载
d <- mtcars

# 不转的话 cyl 是数值 4/6/8,会被当成连续量映射成一根渐变色条,
# 而它其实是三个组 —— 图能画出来,只是图例是错的
d$cyl <- factor(d$cyl)

Palettes

分组图使用 three_body 的蓝、绿、橙红。三条拟合线彼此靠得很近,色相差异比同色系的深浅差异更容易辨认;缸数顺序已经直接写在图例标签里,不再让颜色重复承担排序任务。

fit_cols <- get_palette("three_body", type = "qualitative")
cyl_cols <- setNames(fit_cols, c("4", "6", "8"))

# 基础图也沿用同一套色板:点用蓝,拟合线用橙红
POINT <- fit_cols[[1]]
LINE  <- fit_cols[[3]]

Recipe

No. Method Input Data Palettes
1 ggplot2 + ggpubr d POINT / LINE
2 ggplot2 d cyl_cols

1 · 一条拟合线

不分组,先看整体是什么形状。

#| fig: basic
#| fig-width: 7
#| fig-height: 5
ggplot(d, aes(x = wt, y = mpg)) +
  geom_point(size = 2.6, alpha = 0.85, color = POINT) +
  geom_smooth(
    method  = "lm",       # 必须显式写 —— 默认在 n < 1000 时是 loess,会画出曲线
    formula = y ~ x,      # 显式给公式,否则每次画图都提示一行 message
    se      = TRUE,       # 置信带:它宽不宽比线本身斜不斜更值得看
    color = LINE, fill = LINE, 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
scatter_fit-basic

点云沿一条直线散得挺紧,r 也很高。到这里为止,"车越重越费油"这个结论看着毫无问题。

2 · 按分组拆开

同一份数据,只多映射一个 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
scatter_fit-grouped

6 缸和 8 缸的实线明显比整体虚线平,4 缸线则略陡。整体斜率 −5.34,组内是 −5.65 / −2.78 / −2.19——后两组只有整体的一半上下。差出来的部分主要来自三个组本身就分别占着图的不同角落:8 缸车集中在右下(重、费油),4 缸车集中在左上(轻、省油)。整体拟合线穿过这三团的连线,因此比 6 缸和 8 缸各自的线更陡。

Constraints

  • 整体斜率不等于组内斜率。 上面就是例子:mpg ~ wt 整体 −5.34,6 缸 −2.78、8 缸 −2.19。多出来的那一半来自组间偏移——8 缸车又重又费油,两个方向同时偏,把整条拟合线拽陡了。极端情况下组内斜率会和整体反号,那就是辛普森悖论。
  • 别在小组里认真读斜率。 上面那三组里 6 缸只有 7 辆车。分组拟合最容易出的问题不是算错,是把 n=7 的斜率当成结论。
  • 拟合线不能外推。 它只在数据覆盖的 x 范围内有依据。要放大看局部用 coord_cartesian();xlim() 会把范围外的点连同拟合一起裁掉再重算,得到的是另一条线。
  • 点重叠了就不是散点图了。 几百个点以上会糊成一片,那时该上 alpha、geom_hex() 或者二维密度——散点图的前提是数得清。

两个 recipe 怎么比

  1. 只有两个变量 → recipe 1。没有分组变量可拆的时候,它就是完整答案。
  2. 手上有分组变量,就一定要画一次 recipe 2。 哪怕最后放进正文的是 recipe 1——先看一眼分组会不会改变结论,这一步的成本是一行 color =,不看的代价是整段结论可能是组间偏移伪装出来的。
  3. recipe 2 不一定要进正文。 组数多、每组样本少的时候它是一团互相穿插的线,读起来比 recipe 1 差得多。那种情况下的做法是:自己画一遍确认过,正文放 recipe 1,并在文字里交代分组之后趋势是否一致。
运行环境3 个包 · 2026-09-02T12:54:21.908+0800

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

  • biopalette 0.2.2
  • ggplot2 4.0.3
  • ggpubr 0.6.2