第21讲:虚拟变量、对数变换与随机抽样

数据挖掘与R语言

2026年11月11日

本讲导览

Logs, Categories and a Test Set — 项目五收口课

主要内容

  • 一、随堂测验 —— 先把第20讲的三个数对完账
  • 二、为什么需要对数变换 —— 散点弯了,非线性关系
  • 三、四、五、对数三形态 —— level-log / log-level / log-log,各配一张图
  • 六、分类自变量 —— 两类开关、\(k-1\) 规则、参照组、控制变量的净效应
  • 七、rsample —— 一页示意
  • 八、随堂练习 —— 本讲三题,当堂做,参考答案只在教师版
  • 九、课堂总结 —— 尺度、类别、验收各一句

重要

  • 第19~20讲我们介绍了线性回归模型,如果不是线性关系:①尺度(取对数,转换成线性关系);

  • 前面都是数值型变量,如果是分类变量:②类别("有促销"和无促销);

  • 如何将数据拆分成训练集和验证集:③验收(别用同一批数据既建模又打分)。

  • 本讲内容不考试,目标是"会写、会读、会比",不推公式。

  • 多模型对比:模型一多就要排成一张表对账——本讲用 modelsummary() 把多个模型并排,系数、标准误、R² 一次看全。

学完这一讲你能做到

  • 判断什么时候该取对数,写出 lm(y ~ log(x))、lm(log(y) ~ x)、lm(log(y) ~ log(x))
  • 三种形态三种读法:每 1% 几个单位 / 百分之几 / 弹性;预测时记得 exp() 还原
  • 用 factor(levels = , labels = ) 把类别列转成因子放进 lm(),解释每个虚拟变量系数(照样带"在……不变时")
  • 说清 \(k\) 个类别为什么只生成 \(k-1\) 个系数、没出现那一类去了哪里、换参照组什么不变
  • 用 modelsummary() 排一张表,并说出哪几列的 R² 可以比大小
  • 用 initial_split() 切训练/测试集,在测试集上做样本外预测

开工前:先装包

▶️ 查看代码
# 本讲要用的包(装过一次即可,下次只跑 library)
# install.packages(c("modelsummary", "rsample", "patchwork"))

library(modelsummary)    # 多模型对账表:本讲每节都有一张
library(rsample)         # 随机划分训练/测试集(第七节现场加载)
library(patchwork)       # 两张图并排:第18讲学过

注记

本讲数据:四份 set.seed() 模拟数据(广告、工资、价格、促销)+ 内置 mtcars,造数代码在各节第一页当场可见。dplyr、ggplot2 已随 library(tidyverse) 一起来,不必单装;机房断网时 modelsummary 的表会退化成裸表格,不影响读系数。

一、随堂测验

先把第20讲的三个数对完账,再开始今天的内容

1.1 选择题(每题 1 分钟)


Call:
lm(formula = mpg ~ wt + hp, data = mtcars)

Residuals:
   Min     1Q Median     3Q    Max 
-3.941 -1.600 -0.182  1.050  5.854 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept) 37.22727    1.59879   23.28  < 2e-16 ***
wt          -3.87783    0.63273   -6.13  1.1e-06 ***
hp          -0.03177    0.00903   -3.52   0.0015 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 2.59 on 29 degrees of freedom
Multiple R-squared:  0.827, Adjusted R-squared:  0.815 
F-statistic: 69.2 on 2 and 29 DF,  p-value: 9.11e-12

1. 第20讲 m1 <- lm(mpg ~ wt + hp) 里 wt 的系数是 -3.88。下面哪种读法完整且不错?

  • A. 车重每增 1 千磅,油耗平均增加 3.88 英里/加仑
  • B. 车重每增 1 千磅,每加仑行驶里程平均减少 3.88 英里
  • C. 在马力相同的前提下,车重每增 1 千磅,每加仑行驶里程平均减少 3.88 英里
  • D. 在车重相同的前提下,马力每增 1 马力,里程平均减少 3.88 英里

2. 第20讲那张对账表里,mpg ~ wt 的 R² = 0.753,加上 hp 之后 R² = 0.827。关于 R² 说法正确的是:

  • A. R² 加了变量只会涨不会跌,所以比模型只看 R² 就够
  • B. R² 从 0.753 涨到 0.827,说明 hp 有用
  • C. R² 只涨不跌,所以还要顺带看 Adjusted R²(本例 0.745 → 0.815)
  • D. R² 等于落在回归线上的点的比例

1.2 填空题(每题 1 分钟)

3. 由 summary() 的结果写回归方程时,三个数字都抄 ______ 列;判断某个变量有没有用,看 Pr(>|t|) 列,通常以 ______ 为门槛。

4. 根据模型对新数据做预测,要用:______(m1, newdata = data.frame(wt = 3, hp = 150))。

二、为什么需要对数变换

散点不是一条直线时,lm() 依然只会给你一条直线

2.1 三张散点图:只有一张该用直线

▶️ 查看代码
set.seed(42)                            # 本页只看形状,种子固定即可
n <- 200                                # 每张图 200 个点

d1 <- data.frame(x = runif(n, 1, 50)) %>%         # ① 线性
  mutate(y = 5 + 2 * x + rnorm(n, sd = 8))
p1 <- ggplot(d1, aes(x, y)) +
  geom_point(size = 1.5, alpha = 0.5, color = c_navy) +      # 散点
  geom_smooth(method = "lm", se = FALSE, color = c_green,    # 直线拟合
              linewidth = 1.2) +
  labs(title = "✓ 线性关系", subtitle = "直接用线性回归", x = "x", y = "y")

d2 <- data.frame(x = runif(n, 1, 100)) %>%        # ② 对数增长(收益递减)
  mutate(y = 10 + 30 * log(x) + rnorm(n, sd = 8))
p2 <- ggplot(d2, aes(x, y)) +
  geom_point(size = 1.5, alpha = 0.5, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_orange,   # 硬拟合的直线
              linetype = "dashed", linewidth = 1.2) +
  geom_smooth(method = "loess", se = FALSE, color = c_blue,  # 真实形态
              linewidth = 1.2) +
  labs(title = "对数增长(收益递减)", subtitle = "橙线很差,蓝线才对",
       x = "x", y = "y")

d3 <- data.frame(x = runif(n, 0, 4)) %>%          # ③ 指数增长
  mutate(y = 2 * exp(x) + rnorm(n, sd = 3))
p3 <- ggplot(d3, aes(x, y)) +
  geom_point(size = 1.5, alpha = 0.5, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_orange,
              linetype = "dashed", linewidth = 1.2) +
  geom_smooth(method = "loess", se = FALSE, color = c_blue,
              linewidth = 1.2) +
  labs(title = "指数增长", subtitle = "直线严重低估右侧", x = "x", y = "y")

p1 + p2 + p3                            # patchwork:第18讲的并排写法

2.2 该不该取对数 + 三种形态一张表

判断依据不是"老师让取",而是变量本身的形态:

  • 右偏(长尾):多数值很小、少数值极大——收入、GDP、房价、销售额、资产
  • 相对变化比绝对变化有意义:股价、物价、增长率(涨 100 元不算事,涨 10% 才是事)
  • 边际效益递减:广告费、培训时长、施肥量——第一份投入效果最猛
  • 乘法关系:\(y = a \cdot x^{b}\)(幂函数)、\(y = a \cdot e^{bx}\)(指数)——取对数变加法

警告

取对数有个硬前提:变量必须全为正数。log(0) 是 -Inf、log(-2) 是 NaN,整行数据当场作废。业务表里 0 太常见(当月利润为 0、新增客户为 0),所以取对数前先查最小值:真有 0 就写 log1p(x)(即 \(\ln(x+1)\)),别用均值把 0 填掉,那是伪造数据。

名字 方程 lm() 写法 \(\hat{\beta}_1\) 怎么读 本讲例子
level-log \(y = \beta_0 + \beta_1 \ln x\) lm(y ~ log(x)) \(x\) 增 1% → \(y\) 变 \(\hat{\beta}_1/100\) 个单位 广告费 → 销量
log-level \(\ln y = \beta_0 + \beta_1 x\) lm(log(y) ~ x) \(x\) 增 1 单位 → \(y\) 变约 \(\hat{\beta}_1 \times 100\%\) 经验 → 工资
log-log \(\ln y = \beta_0 + \beta_1 \ln x\) lm(log(y) ~ log(x)) \(x\) 增 1% → \(y\) 变 \(\hat{\beta}_1\%\)(弹性) 价格 → 需求量

注记

三种写法只有一个区别:log() 包在谁身上。 包 \(x\),读法跟着 \(x\) 变;包 \(y\),预测要多一步还原;两边都包,\(\hat{\beta}_1\) 直接就是弹性。变换之后模型仍然对系数线性,summary()、predict()、modelsummary() 全部照用。

三、level-log:只对 \(x\) 取对数

广告费 → 销量:\(x\) 每增加 1%,\(y\) 增加几个单位?

3.1 数据长什么样:钱越花越多,销量越涨越慢

▶️ 查看代码
set.seed(7)                             # 写死种子:全班数字一样
n <- 80                                 # 80 个月的投放记录
ad_log <- data.frame(广告费 = runif(n, 1, 200)) %>%   # 万元,均匀分布
  mutate(销量 = 10 + 25 * log(广告费) + rnorm(n, sd = 8))  # 万件 + 噪声

p_raw <- ggplot(ad_log, aes(x = 广告费, y = 销量)) +  # 左图:原始尺度
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_orange,   # 硬拟合的直线
              linetype = "dashed", linewidth = 1) +
  geom_smooth(method = "loess", se = FALSE, color = c_blue,  # 数据真实形态
              linewidth = 1) +
  labs(title = "原始散点:明显是弯的", subtitle = "橙线差,蓝线才对",
       x = "广告费(万元)", y = "销量(万件)")

p_log <- ggplot(ad_log, aes(x = log(广告费), y = 销量)) +  # 右图:x 取对数
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_green,
              linewidth = 1.2) +
  labs(title = "对 x 取对数后:直了", subtitle = "ln(广告费) 与销量线性",
       x = "ln(广告费)", y = "销量(万件)")

p_raw + p_log                           # patchwork:同一数据两种尺度

3.2 建模、读系数与对账

▶️ 查看代码
ad1 <- lm(销量 ~ log(广告费), data = ad_log)   # level-log:只包 x
summary(ad1)$coef          # 紧凑系数表:估计/标准误/t/p/R²在下方
            Estimate Std. Error t value Pr(>|t|)
(Intercept)     8.09       4.59    1.76 8.17e-02
log(广告费)    25.61       1.02   25.10 4.18e-39

提示

level-log 口诀:\(x\) 每增加 1%,\(y\) 增加约 \(\hat{\beta}_1 / 100\) 个单位。

本例:广告费每增加 1%,销量平均增加 \(\hat{\beta}_1/100 =\) 0.256 万件;广告费翻一番,销量增加 \(\hat{\beta}_1 \times \ln 2 \approx\) 17.75 万件。R 的 log() 默认是自然对数 ln,不是 log10。

警告

最常见的错读:"广告费每增加 1,销量增加 25.61"。\(x\) 一旦取了对数,"每增 1 单位"就不存在了,只能问"增 1%"。

▶️ 查看代码
ad0 <- lm(销量 ~ 广告费, data = ad_log)          # 直线版(第20讲的老写法)
ad2 <- lm(销量 ~ 广告费 + I(广告费^2), data = ad_log)  # 只为验弯曲:加二次项

modelsummary(list("直线" = ad0, "level-log" = ad1),   # 两个模型并排
             stars = TRUE)                             # 星号标显著性
直线 level-log
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
(Intercept) 87.136*** 8.087+
(2.558) (4.585)
广告费 0.329***
(0.022)
log(广告费) 25.605***
(1.020)
Num.Obs. 80 80
R2 0.748 0.890
R2 Adj. 0.744 0.888
AIC 611.5 545.2
BIC 618.7 552.4
Log.Lik. -302.774 -269.607
F 230.947 629.924
RMSE 10.65 7.04

这张表怎么读(\(y\) 两边都是"销量"、没变,所以可以正相比大小):R² 0.748 → 0.890,残差标准差 10.72 → 7.08 万件(生成时的噪声是 8)。再拿二次项验弯曲:lm(销量 ~ 广告费 + I(广告费^2)) 的二次项 p = 3.7e-10,确实弯——取对数不是多此一举。

四、log-level:只对 \(y\) 取对数

经验 → 工资:\(x\) 增加 1,\(y\) 变化百分之几?

4.1 数据长什么样:工资的增长是"利滚利"式的

▶️ 查看代码
set.seed(21)                            # 沿用上学期第12讲的种子
n <- 100                                # 100 位员工的横截面
exp_data <- data.frame(经验年数 = runif(n, 0, 30)) %>%   # 0—30 年
  mutate(工资 = exp(7.5 + 0.06 * 经验年数 + rnorm(n, sd = 0.25)))

q1 <- ggplot(exp_data, aes(x = 经验年数, y = 工资)) +    # 左:原始尺度
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_orange,
              linetype = "dashed", linewidth = 1) +
  geom_smooth(method = "loess", se = FALSE, color = c_blue, linewidth=1) +
  labs(title = "原始:工资 ~ 经验(上凸)", subtitle = "直线两头都拟合不好",
       x = "工作经验(年)", y = "工资(元/月)")

q2 <- ggplot(exp_data, aes(x = 经验年数, y = log(工资))) +  # 右:y 取对数
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_green, linewidth=1.2) +
  labs(title = "对 y 取对数后:直了", subtitle = "ln(工资) ~ 经验 线性",
       x = "工作经验(年)", y = "ln(工资)")

q1 + q2                                 # 同一份数据,只换纵轴尺度

4.2 建模、读系数,以及"R² 不能比"

▶️ 查看代码
ex1 <- lm(log(工资) ~ 经验年数, data = exp_data)  # log-level:只包 y
summary(ex1)$coef        # 左边是 ln(工资):估计/标准误/t/p 一行一个
            Estimate Std. Error t value  Pr(>|t|)
(Intercept)   7.4782    0.05190   144.1 6.78e-116
经验年数      0.0604    0.00273    22.1  6.34e-40

提示

log-level 口诀:\(x\) 每增加 1 个单位,\(y\) 变化约 \(\hat{\beta}_1 \times 100\%\)。

本例:经验每多 1 年,工资平均高约 6.04%(精确式 \((e^{\hat{\beta}_1}-1)\times100\%\) = 6.2%,\(\hat{\beta}_1\) 小的时候两者几乎一样,大于 0.2 时必须用精确式)。

这就是经济学里最有名的 Mincer 工资方程。生成数据用的真值是 0.06,估出 0.0604——模拟数据的好处:能拿估计值和真值对表。

警告

两个高频错误:① 说成"经验每增 1 年,工资增加 0.0604 元"——左边已经是 \(\ln(\text{工资})\),不再是元;② 把 0.0604 乘 100 说成"涨 6 个百分点",单位应是 %(工资 5000 涨 6% 是涨 300 元)。

▶️ 查看代码
ex0 <- lm(工资 ~ 经验年数, data = exp_data)         # 直线:y 是元

modelsummary(list("直线" = ex0, "log-level" = ex1), stars = TRUE)
直线 log-level
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
(Intercept) 569.354 7.478***
(364.806) (0.052)
经验年数 309.618*** 0.060***
(19.167) (0.003)
Num.Obs. 100 100
R2 0.727 0.833
R2 Adj. 0.724 0.832
AIC 1779.0 1705.5
BIC 1786.8 1713.3
Log.Lik. -886.511 -0.733
F 260.944 490.352
RMSE 1713.29 0.24

表里 R² = 0.727 和 0.833 不能比大小——一个是"解释了工资的几分之几",另一个是"解释了 ln(工资) 的几分之几",分母不是同一个东西。Std. Error 同理(一边是元、一边是对数单位)。

那凭什么说变换有用? 三条不依赖 R² 的证据:① 4.1 右图那条线直了;② 残差图从"两头正、中间负"的弧形变成随机撒在 0 线上下;③ 直线版残差标准差 1721.9 元(工资高的人更散,喇叭口),log-level 版 0.245(对数尺度上稳定)。板书记一句:比模型,先看 \(y\)。

4.3 预测要多一步:exp() 还原

▶️ 查看代码
new_one  <- data.frame(经验年数 = 10)           # 要预测的那个人:10 年经验
pred_log <- predict(ex1, newdata = new_one)     # 模型给的是 ln(工资)
pred_yuan <- exp(pred_log)                      # 反对数:还原成元/月
c(ln值 = round(pred_log, 3), 元每月 = round(pred_yuan, 0))  # 两个数一起印
  ln值.1 元每月.1 
    8.08  3236.00 

重要

模型拟合的是 \(\ln(\text{工资})\),所以 predict() 出来的是 8.082 这个对数尺度上的数——直接念出来就是错。必须 exp() 还原:约 3236 元/月。

▶️ 查看代码
curve_data <- data.frame(经验年数 = seq(0, 30, length.out = 200)) %>%
  mutate(预测工资 = exp(predict(ex1, newdata = pick(经验年数))))  # 逐点还原

ggplot(exp_data, aes(x = 经验年数, y = 工资)) +  # 散点:原始尺度
  geom_point(size = 2, alpha = 0.6, color = c_navy) +
  geom_line(data = curve_data, aes(x = 经验年数, y = 预测工资),
            color = c_green, linewidth = 1.3) +  # 还原后的拟合曲线
  labs(title = "log-level 模型放回原始尺度:一条指数曲线",
       subtitle = "绿线 = exp(截距 + 斜率 × 经验),正好顺着点走",
       x = "工作经验(年)", y = "工资(元/月)")

五、log-log:两边同时取对数

价格 → 需求量:斜率直接就是弹性

5.1 数据长什么样:一条幂函数曲线

▶️ 查看代码
set.seed(99)                            # 沿用上学期第12讲的种子
n <- 80                                 # 80 个价格—销量观测
price_data <- data.frame(价格 = runif(n, 5, 100)) %>%   # 元
  mutate(需求量 = exp(8 - 1.3 * log(价格) + rnorm(n, sd = 0.2)))

w1 <- ggplot(price_data, aes(x = 价格, y = 需求量)) +   # 左:原始尺度
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_orange,
              linetype = "dashed", linewidth = 1) +
  labs(title = "原始:双曲线下坠", subtitle = "直线在低价区严重偏高",
       x = "价格(元)", y = "需求量(件)")

w2 <- ggplot(price_data, aes(x = log(价格), y = log(需求量))) +  # 右:双对数
  geom_point(size = 2, alpha = 0.7, color = c_navy) +
  geom_smooth(method = "lm", se = FALSE, color = c_green, linewidth=1.2) +
  labs(title = "两边都取对数:一条直线", subtitle = "斜率 = 弹性",
       x = "ln(价格)", y = "ln(需求量)")

w1 + w2                                 # 左:幂函数;右:线性化

5.2 建模:\(\hat{\beta}_1\) 就是弹性,四种写法排成一张表

▶️ 查看代码
pr3 <- lm(log(需求量) ~ log(价格), data = price_data)  # log-log:两边都包
summary(pr3)$coef           # 只看斜率那一行:log(价格) 的估计值
            Estimate Std. Error t value Pr(>|t|)
(Intercept)     7.91     0.1114    71.0 1.25e-72
log(价格)      -1.29     0.0289   -44.5 3.31e-57

提示

log-log 口诀(最省事):\(x\) 每增加 1%,\(y\) 变化 \(\hat{\beta}_1\%\)。

本例斜率 -1.29(真值 −1.3)→ 价格每涨 1%,需求量平均降约 72.4%。\(|\hat{\beta}_1| > 1\) 富有弹性(涨价很伤销量),\(|\hat{\beta}_1| < 1\) 缺乏弹性(如必需品)。

一句话就能给经营结论:价格翻倍,需求量变成 \(2^{\hat{\beta}_1} - 1 =\) -59.0%——这就是定价会上要的那个数字。

▶️ 查看代码
pr0 <- lm(需求量 ~ 价格, data = price_data)           # ① 都不取
pr1 <- lm(需求量 ~ log(价格), data = price_data)      # ② 只取 x
pr2 <- lm(log(需求量) ~ 价格, data = price_data)      # ③ 只取 y

modelsummary(list("① 原始" = pr0, "② level-log" = pr1,
                  "③ log-level" = pr2, "④ log-log" = pr3), stars = TRUE)
① 原始 ② level-log ③ log-level ④ log-log
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
(Intercept) 119.099*** 310.736*** 4.788*** 7.910***
(11.447) (18.678) (0.094) (0.111)
价格 -1.500*** -0.032***
(0.190) (0.002)
log(价格) -71.928*** -1.286***
(4.842) (0.029)
Num.Obs. 80 80 80 80
R2 0.445 0.739 0.848 0.962
R2 Adj. 0.438 0.736 0.846 0.962
AIC 843.5 783.1 561.0 450.0
BIC 850.6 790.3 568.2 457.1
Log.Lik. -418.745 -388.574 -34.366 21.164
F 62.500 220.713 435.819 1981.308
RMSE 45.39 31.13 0.37 0.19

这张表要分两组看(规矩:\(y\) 相同才能比):

  • ① vs ②(\(y\) 都是需求量,可比):R² 0.445 → 0.739 ⇒ 光把 \(x\) 拉直就涨了一大截
  • ③ vs ④(\(y\) 都是 \(\ln(\text{需求量})\),可比):R² 0.848 → 0.962 ⇒ \(x\) 也该取对数
  • ② vs ③、① vs ④ 一律不许比大小:\(y\) 换了尺度,R² 和 AIC 都不是同一把尺子量出来的数

5.3 三种形态的曲线:一眼分清

三种形态只有一个区别——log() 包在谁身上(回到 2.2 那张表):

形态 长什么样 一句话记忆 本讲例子
level-log 慢慢涨,越涨越平 收益递减 广告费 → 销量
log-level 越涨越快 利滚利(复利) 经验 → 工资
log-log 往下掉的双曲线 弹性,涨价伤量 价格 → 需求量

六、分类自变量:两类到 \(k\) 类

把"有促销 / 无促销"这样的文字,变成能乘系数的数

6.1 思路:用 0/1 开关代替文字

回归方程只会做乘法和加法,"有促销" 这种文字没法乘系数。办法是配一个 0/1 开关(虚拟变量,dummy variable):

\[D_{\text{有促销}} = \begin{cases} 1 & \text{有促销} \\ 0 & \text{无促销(参照组)} \end{cases} \qquad \widehat{\text{销量}} = \beta_0 + \beta_1 \times \text{广告费} + \beta_2 D_{\text{有促销}}\]

  • 无促销:\(D = 0\) → \(\hat{y} = \hat{\beta}_0 + \hat{\beta}_1 \times\) 广告费
  • 有促销:\(D = 1\) → 整条线上移 \(\hat{\beta}_2\)
  • 所以 \(\hat{\beta}_2\) = 两组之间的平均差(在广告费相同的前提下)

提示

不用手工造开关。 只要那一列是因子(factor),R 会自动生成开关;levels 里的第一个类别自动成为参照组(基准组),它不出现在系数表里——被藏进截距。

6.2 数据长什么样:同一个广告价,两种命运

▶️ 查看代码
set.seed(5)                             # 沿用上学期第12讲的种子
n <- 100                                # 100 个月
promo_data <- data.frame(
  广告费 = runif(n, 10, 80),                        # 万元
  促销 = sample(c("无促销", "有促销"), n, replace = TRUE)  # 文字类别
) %>%
  mutate(促销虚拟 = if_else(促销 == "有促销", 1, 0),  # 手工造开关(只为演示)
         销量 = 5 + 1.2 * 广告费 + 15 * 促销虚拟 +     # 真值:促销 +15 万件
           rnorm(n, sd = 6))

ggplot(promo_data, aes(x = 广告费, y = 销量, color = 促销)) +
  geom_point(size = 2, alpha = 0.7) +                # 两组点,同一横轴
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.2) +  # 各自一条线
  scale_color_manual(values = c("无促销" = c_blue, "有促销" = c_orange)) +
  labs(title = "两条几乎平行的回归线",
       subtitle = "斜率差不多,截距差 ≈ 15:那就是虚拟变量系数",
       x = "广告费(万元)", y = "销量(万件)") +
  theme(legend.position = "top")

6.3 建模、读系数与对账

▶️ 查看代码
c1 <- lm(销量 ~ 广告费 + 促销, data = promo_data)   # 促销是文字,R 自动转因子
summary(c1)$coef          # 类别那行读作"与基准比":促销有促销
            Estimate Std. Error t value Pr(>|t|)
(Intercept)     5.74     1.6166    3.55 5.92e-04
广告费          1.20     0.0299   39.96 4.79e-62
促销有促销     13.89     1.2726   10.92 1.41e-18

提示

促销有促销 = 13.89 的读法(三个要素一个都不能少):

在广告费相同的前提下,有促销的月份比无促销的月份销量平均高 13.89 万件(p = 1.4e-18)。

这是促销的净效应——已经把"促销月往往多花钱投放"这层影响剥掉了。行名是列名 + 类别名拼出来的,第一次见会懵,指一遍给学生;截距 5.74 是无促销(参照组)那条线的起点。真值 15,估出 13.89。

▶️ 查看代码
c0 <- lm(销量 ~ 广告费, data = promo_data)          # 只放钱:促销被漏掉

modelsummary(list("只有广告费" = c0, "广告费 + 促销" = c1), stars = TRUE)
只有广告费 广告费 + 促销
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
(Intercept) 11.654*** 5.742***
(2.263) (1.617)
广告费 1.201*** 1.197***
(0.044) (0.030)
促销有促销 13.895***
(1.273)
Num.Obs. 100 100
R2 0.882 0.947
R2 Adj. 0.880 0.946
AIC 735.5 657.4
BIC 743.3 667.8
Log.Lik. -364.764 -324.688
F 729.127 863.911
RMSE 9.29 6.22

\(y\) 两边都是销量 → 可以比:R² 0.882 → 0.947。注意 广告费 那一行几乎没动(1.201 → 1.197),因为本数据里促销与广告费独立(相关 0.013)。6.9 会遇到"一动就是几倍"的情形,那才是控制变量的重点。

6.4 规则:\(k\) 个类别只要 \(k-1\) 个开关

例:营销渠道有三类(线下门店 / 社交媒体 / 搜索引擎),只需要两个开关:

渠道 \(D_{\text{社交媒体}}\) \(D_{\text{搜索引擎}}\) 预测值
线下门店(参照组) 0 0 \(\hat{\beta}_0\)
社交媒体 1 0 \(\hat{\beta}_0 + \hat{\beta}_1\)
搜索引擎 0 1 \(\hat{\beta}_0 + \hat{\beta}_2\)
  • \(\hat{\beta}_0\):参照组(线下门店)的基准水平
  • \(\hat{\beta}_1\):社交媒体与线下门店相比的差;\(\hat{\beta}_2\) 同理
  • 第三个开关去哪了? 它不需要:线下门店 = "两个开关都是 0"

重要

如果三个开关一起塞进 lm() 会怎样? 三列之和恒等于 1,与截距那一列完全重复(完全多重共线性),最小二乘无解——6.8 现场演示。

四类、五类……依此类推:永远只放 \(k-1\) 个开关,lm() 会替你处理,但你必须知道参照组是谁,否则系数念不对。

6.5 案例:气缸数(三类)对油耗的影响

▶️ 查看代码
mc <- mtcars %>%                                  # 32 辆车
  mutate(cyl_f = factor(cyl, levels = c(4, 6, 8),  # 数值 4/6/8 → 三类因子
                        labels = c("四缸", "六缸", "八缸")))
cyl_m <- lm(mpg ~ cyl_f, data = mc)               # 参照组 = levels 第一个
summary(cyl_m)                                    # 三行:截距 + 两个开关

Call:
lm(formula = mpg ~ cyl_f, data = mc)

Residuals:
   Min     1Q Median     3Q    Max 
-5.264 -1.836  0.029  1.389  7.236 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)   26.664      0.972   27.44  < 2e-16 ***
cyl_f六缸     -6.921      1.558   -4.44  0.00012 ***
cyl_f八缸    -11.564      1.299   -8.90  8.6e-10 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 3.22 on 29 degrees of freedom
Multiple R-squared:  0.732, Adjusted R-squared:  0.714 
F-statistic: 39.7 on 2 and 29 DF,  p-value: 4.98e-09
系数 数值 怎么读(必须带参照组)
截距 26.66 四缸车的平均里程
cyl_f六缸 -6.92,p = 0.00012 六缸比四缸平均少跑这些英里/加仑
cyl_f八缸 -11.56,p = 8.6e-10 八缸比四缸平均少跑这些英里/加仑

警告

标签要写成"四缸/六缸/八缸"这种中文数字:若写 labels = c("4缸","6缸","8缸"),R 会把以数字开头的名字改成 cyl_fX4缸,系数表看起来像乱码。

6.6 图:系数就是"组均值的高度差"

▶️ 查看代码
cyl_sum <- mc %>%                                 # 每组的均值与 95% 置信区间
  group_by(cyl_f) %>%
  summarise(均值 = mean(mpg), se = sd(mpg)/sqrt(n()), .groups = "drop") %>%
  mutate(下限 = 均值 - 1.96 * se, 上限 = 均值 + 1.96 * se)

g1 <- ggplot(mc, aes(x = cyl_f, y = mpg, fill = cyl_f)) +
  geom_boxplot(alpha = 0.6, width = 0.5) +        # 左:三组分布
  geom_jitter(aes(color = cyl_f), width = 0.1, size = 2, alpha = 0.7) +
  scale_fill_manual(values = c("四缸" = c_green, "六缸" = c_blue,
                               "八缸" = c_orange)) +
  scale_color_manual(values = c("四缸" = c_green, "六缸" = c_blue,
                                "八缸" = c_orange)) +
  labs(title = "各气缸数的油耗分布", x = "气缸数", y = "mpg") +
  theme(legend.position = "none")

g2 <- ggplot(cyl_sum, aes(x = cyl_f, y = 均值, color = cyl_f)) +
  geom_point(size = 4) +                          # 右:组均值 ± 1.96 se
  geom_errorbar(aes(ymin = 下限, ymax = 上限), width=0.15, linewidth=1) +
  geom_hline(yintercept = cyl_sum$均值[1], linetype = "dashed",
             color = "grey60") +                  # 虚线 = 参照组基准(截距)
  labs(title = "组均值与 95% 置信区间",
       subtitle = "系数 = 各组均值与虚线(四缸)的高度差",
       x = "气缸数", y = "mpg") +
  scale_color_manual(values = c("四缸" = c_green, "六缸" = c_blue,
                                "八缸" = c_orange)) +
  theme(legend.position = "none")

g1 + g2                                           # patchwork 并排

七、随机抽样:给模型留一场开卷考试

rsample 三行:划分 → 训练 → 测试

7.1 一页示意:样本外才算真成绩

▶️ 查看代码
library(rsample)                        # 本讲唯一的新包:现场加载
set.seed(42)                            # 种子写进代码,别人才能复现
sp <- initial_split(promo_data, prop = 0.8)   # 随机 8:2 划分
tr <- training(sp)                      # 训练集:模型用它拟合
te <- testing(sp)                       # 测试集:模型从没见过

rs_m <- lm(销量 ~ 广告费 + 促销, data = tr)    # 只在训练集上建模
# 均方根误差:预测值与真实值的平均偏差
rmse <- function(m, d) sqrt(mean((d$销量 - predict(m, newdata = d))^2))
# 训练/测试各打一分:样本外才是真成绩
c(训练集 = round(rmse(rs_m, tr), 2), 测试集 = round(rmse(rs_m, te), 2))
训练集 测试集 
  5.94   7.24 
▶️ 查看代码
pred_te <- predict(rs_m, newdata = te)          # 样本外预测
bad_i   <- which.max(abs(te$销量 - pred_te))    # 误差最大的那一行

set.seed(7)                             # 换一组种子重跑:只为验证"结论会漂"
sp7 <- initial_split(promo_data, prop = 0.8)
rs7 <- lm(销量 ~ 广告费 + 促销, data = training(sp7))
rm7 <- c(训练 = round(rmse(rs7, training(sp7)), 2),   # 正文引用,不单独印
         测试 = round(rmse(rs7, testing(sp7)), 2))
  • 划分结果:训练 80 行 / 测试 20 行;促销系数在训练集上是 14.04,与全样本的 13.89 接近
  • 测试集 RMSE 比训练集大(上面那两个数)——模型在训练集上"背过答案",样本外才露馅
  • 最吃亏的那一行:无促销、广告费 32.29 万元,真实 59.35 vs 预测 44.27 万件

警告

一次划分不稳。 换 set.seed() 再跑,两个 RMSE 会一起变,甚至测试集反而更低(同一段代码把种子换成 7:训练 6.56 / 测试 4.71)。所以报结论要带上"换种子会变"这句自觉,实验报告里必须把 set.seed() 写进代码。

八、随堂练习:本讲三题

三题当堂做,参考答案只在教师版显示

8.1 【练习】三种形态该用哪一种

只写形态名(level-log / log-level / log-log / 不变换),一句话说明理由:

  1. 分析"客户数随曝光量增长",第一个 10 万曝光效果最猛,后面越来越平
  2. 银行存款按固定年利率复利增长,想知道每年增长百分之几
  3. 研究"家庭收入每增加 1%,消费支出增加百分之几"
  4. 学生每周学习时长与期末分数,散点看就是一条直线

8.2 【练习】把参照组换成"有促销"

动手(3 分钟):把 levels 顺序改成 c("有促销", "无促销") 重新拟合,回答三问:

  1. 系数表里那一行叫什么名字?数值是多少?
  2. 截距变成什么?
  3. R² 变不变?fitted() 变不变?

8.3 【练习】mtcars 的 am:手动挡对油耗的影响有多大

内置数据 mtcars 里 am 是变速箱:0 = 自动挡、1 = 手动挡,共 32 辆车(第20讲用它建过 mpg ~ wt)。做三件事:

  1. 用 factor(am, levels = c(0, 1), labels = c("自动挡", "手动挡")) 造一个因子列,拟合 lm(mpg ~ am_f):参照组是谁?系数怎么读?
  2. 用分组均值验一下:自动挡与手动挡的平均里程各是多少,差是多少?
  3. 再拟合 lm(mpg ~ wt + am_f),手动挡那一行的系数和 p 值变成什么?这说明什么?

九、课堂总结:尺度、类别、验收

三样工具各收一句,回家写实验报告 9 时照抄这个顺序

9.1 一张表总结:三样工具,一张地图

场景 R 写法 怎么读 报告交什么
① 左密右疏 lm(y ~ log(x)) x 加 1%,y 加 β₁/100 方程 + 单位
① 利滚利 lm(log(y) ~ x) x 加 1,y 加 β₁×100% exp() 还原
① 要弹性 lm(log(y) ~ log(x)) x 加 1%,y 加 β₁% 直接叫弹性
② 促销/气缸 factor(levels = ) 与参照组比,k−1 个开关 读法带参照组
③ 怕背答案 initial_split(prop = 0.8) 测试集 RMSE 才算成绩 报误差 + 换种子

9.2 常见错误对照表

提示

三句口诀:① 比模型,先看 \(y\)。② 类别进方程,先说参照组。③ 预测要不要 exp(),只看模型左边是什么。

高频错误 正确说法
"广告费每增加 1,销量增加 25.61" level-log 只能问"增 1%",\(y\) 变 \(\hat{\beta}_1/100\) 个单位(本例 0.256 万件)
predict() 的对数结果直接当工资 log-level 要 exp() 还原:8.082 → 3236 元/月
跨 \(y\) 比 R²(0.727 vs 0.833) 同 \(y\) 才可比;跨 \(y\) 看散点形态 + 残差图
对可能为 0 的变量直接 log() log(0) = -Inf 整行作废;先查最小值或用 log1p()
lm(mpg ~ cyl) 把类别当数值 先 factor(levels = , labels = ),读系数带参照组
自己造满 \(k\) 个 0/1 列一起塞 只能 \(k-1\) 个,第 \(k\) 列会被算成 NA
"换了参照组,结论反了" 只改说法:R²、fitted() 不变,系数名称/符号变
说"促销有效"却不控制广告费 类别要与连续变量一起进模型,读成"在……不变时"

9.3 Exit Ticket —— 写给自己的三个问题

请花 1 分钟,在纸条或在线问卷上简要回答(不计分,用来决定第22讲总复习的侧重点):

  1. 三种对数形态各读作什么?任选一种,用一句话把它的 \(\hat{\beta}_1\) 念对。
  2. levels 里排在第一个的类别,去哪儿了?(一句话)
  3. modelsummary 那张四列表里,为什么前两列的 R² 能比大小、后两列不能?

注记

顺手自查:今天这几件事你都能做到了吗——判断该不该取对数 ✓ 写出三种 lm() 并各说一句读法 ✓ 给 log(y) 模型做完预测并还原 ✓ 说清参照组与 \(k-1\) ✓ 判断两张表能不能比 R² ✓ 用 initial_split() 算测试集 RMSE

课后作业与下讲预告

实验报告 9

  • 实验报告 9已单独发布
  • 课程论文,题目、数据、提交格式与截止时间以任务单为准。

第22讲:学期总复习与期末备考

  • 五大项目知识地图串讲:基础R → 数据准备 → 数据清洗 → 数据可视化 → 线性回归
  • 一条链路端到端演示:lm() 的写法、读法、比法、验法
  • 期末卷型、高频考点与易错点清单

谢谢!

第21讲:虚拟变量、对数变换与随机抽样 —— 项目五收口

让线性回归解决非线性问题;让分类变量进入回归模型。

朱 奇 | 锦城大学 · 财会学院