04 多层感知机

本章的练习围绕四个问题展开:非线性究竟解决了什么、模块化如何组织模型、损失函数如何决定梯度、以及正则化如何在 torch 中落地。题目与正文的对应关系如下:

建议总用时 2.5~3 小时。编程题请先读完题干再动手,固定随机种子后再比较结果。

一、概念与推导

练习 1:参数量与线性坍塌

第 4 章用下面这个网络做回归:

net <- nn_sequential(
  nn_linear(13, 32), nn_relu(),
  nn_linear(32, 16), nn_relu(),
  nn_linear(16, 1)
)

请回答:

  • (a) 逐层计算这个网络的可训练参数量(含偏置),并给出总数。如果所有线性层都设置 bias = FALSE,总数又是多少?
  • (b) 如果把两个 nn_relu() 都删掉,证明这个三层网络严格等价于一个单层线性模型,并写出等价层的形状与参数量。
  • (c) 为什么加入一个 ReLU 之后,(b)中的等价关系就失效了?请从“分段线性”的角度解释 ReLU 是如何让网络表达 \(x_1 \cdot x_2\) 这类交互项的。
  • (d) 如果把两个隐藏层都缩窄到 2 维,即 13 → 2 → 2 → 1,网络还能拟合第 4 章那份交互数据吗?请结合矩阵的秩,以及有/无激活函数两种情况分别讨论。

(a) 每个 nn_linear(in, out) 的参数量是 \(in \times out + out\)(权重矩阵加偏置向量):

\[ (13 \times 32 + 32) + (32 \times 16 + 16) + (16 \times 1 + 1) = 448 + 528 + 17 = 993 \]

若 bias = FALSE,每层只算权重:\(416 + 512 + 16 = 944\)。可以在 R 里直接验证:

length(net$parameters)                                   # 6 个参数张量
sum(sapply(net$parameters, function(p) p$numel()))       # 993

(b) 记三层分别为

\[ h = W_1 x + b_1,\qquad g = W_2 h + b_2,\qquad y = W_3 g + b_3 \]

逐层代入:

\[ y = W_3 (W_2 (W_1 x + b_1) + b_2) + b_3 = (W_3 W_2 W_1)\, x + (W_3 W_2 b_1 + W_3 b_2 + b_3) \]

令 \(W' = W_3 W_2 W_1\),\(b' = W_3 W_2 b_1 + W_3 b_2 + b_3\),整个网络就是 \(y = W' x + b'\)。\(W'\) 的形状是 \(1 \times 13\),因此等价层是 nn_linear(13, 1),参数量为 \(13 + 1 = 14\)。

993 个参数的网络与 14 个参数的线性回归属于同一个函数类(仿射函数)。参数更多不代表表达能力更强——多出来的参数只是在同一个函数上做了冗余的参数化。实际上,\(W'\) 的秩不超过 \(\min(1, 16, 32, 13) = 1\):无论隐藏层多宽,输入都会被压缩到一条直线上再读出,这个结论不变。

(c) 仿射映射的复合仍然是仿射映射,这是(b)成立的全部原因。ReLU 不是仿射映射,复合之后就不能再合并成一个矩阵了。

ReLU 是分段线性的:第一层的每个隐藏单元都在输入空间里划出一条“折痕”(\(W_1\) 的某一行与 \(x\) 的内积过零的位置),折痕两侧的斜率不同;更深的层则在上一层造出的特征空间里继续折,折痕映回输入空间后是弯折的曲线。\(H\) 个隐藏单元最多把输入空间切成若干块,每一块内部网络都是仿射的,块与块之间斜率发生跳变。\(x_1 \cdot x_2\) 是一个马鞍面:在第一、三象限向上弯,在第二、四象限向下弯。用许多小平面去拼这个曲面,就能在有限精度内逼近它。第 4 章训练出的 R² ≈ 0.92,正是“用折线拼曲面”的结果。

注意一个反差:ReLU 本身没有参数(nn_relu() 的可训练参数为 0),所以加回激活函数后总参数量仍然是 993。参数没变,函数类却从“一个仿射函数”变成了“大量分段仿射函数的集合”。第一层的 32 个单元在输入空间里、第二层的 16 个单元在隐藏特征空间里,各自贡献一条由权重和偏置决定的折痕——这才是那 993 个参数真正开始“分工”的地方。

(d) 分两种情况。

无激活函数时:宽度缩小到 2 不会改变任何东西。由上文的秩分析,\(W'\) 始终是 \(1 \times 13\) 的矩阵,函数类仍然是仿射函数;隐藏层是 2 维还是 32 维,只是参数化方式不同。

有激活函数时:宽度就是容量。每个隐藏单元贡献一个折痕,2 个单元最多只能折出很少的线性区域,无法同时覆盖四个象限里方向相反的弯曲。用第 4 章的交互数据、按练习自设的配置(set.seed(42),\(n = 1000\),Adam,1000 轮)实测:

隐藏单元数 测试 R²
2 0.4455
4 0.8828
10 0.9222
线性回归 0.0016

所以答案是:基本不能。这个对比也说明了(b)(c)的分界——没有非线性时,宽度无关紧要;有了非线性,宽度才成为控制模型容量的旋钮。

练习 2:激活函数的导数与梯度消失

请回答:

  • (a) 分别求 Sigmoid、Tanh、ReLU 的导数,并尽量写成只依赖函数输出值的形式。
  • (b) 证明 Sigmoid 的导数满足 \(\sigma'(x) \le \frac{1}{4}\),并指出等号成立的位置。
  • (c) 假设一个 \(L\) 层网络的所有隐藏层都使用 Sigmoid,且每层线性部分的输出恰好落在导数最大的位置。以 \(\frac{1}{4}\) 作为每层梯度传递因子的上界,估算 \(L = 10\) 时梯度会衰减到原来的多少。若换成 ReLU(神经元处于激活区)呢?
  • (d) ReLU 在 \(x = 0\) 处不可导,为什么实际训练不受影响?什么是“死亡 ReLU”?

(a)

\[ \sigma'(x) = \sigma(x)\bigl(1 - \sigma(x)\bigr) \]

\[ \tanh'(x) = 1 - \tanh^2(x) \]

\[ \text{ReLU}'(x) = \begin{cases} 1, & x > 0 \\ 0, & x < 0 \end{cases} \]

三者都能只靠输出值算出来(ReLU 还需要知道输入的符号),这正是反向传播中“保存前向输出、避免重复计算”的由来。

(b) 把 \(\sigma\) 简记为 \(s\),则

\[ s(1 - s) = \frac{1}{4} - \left(s - \frac{1}{2}\right)^2 \le \frac{1}{4} \]

等号在 \(s = \frac{1}{2}\) 时成立,也就是 \(x = 0\) 时。\(x\) 偏离 0 之后,导数迅速下降:\(\sigma'(\pm 3) \approx 0.045\),\(\sigma'(\pm 5) \approx 0.0066\)。

(c) 反向传播时,梯度每穿过一层 Sigmoid 就乘上一个不超过 \(\frac{1}{4}\) 的因子。10 层连乘:

\[ \left(\frac{1}{4}\right)^{10} = \frac{1}{1{,}048{,}576} \approx 9.54 \times 10^{-7} \]

梯度衰减了大约 6 个数量级,最前面的层几乎收不到有效的更新信号。换成 ReLU 时,只要神经元处于激活区,导数恒为 1,连乘结果仍是 1,梯度可以“原样”传回浅层。

这个估算刻意取了最乐观的情形(每层都在导数最大处);真实网络里 Sigmoid 常常工作在饱和区,衰减会更快。这也是 Sigmoid 几乎不再用于隐藏层的原因。

(d) 严格来说 ReLU 在 0 点只有一个次梯度区间 \([0, 1]\),任取其中一个值都是合法的下降方向。深度学习框架通常约定 \(\text{ReLU}'(0) = 0\),这只是一个实现约定;由于输入是连续值,恰好等于 0 的概率几乎为零,单点的取值不影响优化过程。反过来说,即使把 0 点的导数改成 1,训练结果也不会有可察觉的差别。

“死亡 ReLU”是另一回事:如果某个神经元的输入在所有样本上都是负数,它的输出恒为 0,导数恒为 0,权重和偏置就再也收不到梯度,永久停止更新。常见诱因是学习率过大(一步把偏置推到很负的位置)或初始化不当。缓解手段包括减小学习率、使用更合适的初始化,或者改用 Leaky ReLU 这类在负区间保留小斜率的激活函数。

练习 3:损失函数与梯度

请回答:

  • (a) 设 \(z\) 是二分类网络最后一层的原始输出(Logit),\(p = \sigma(z)\),真实标签 \(y \in \{0, 1\}\)。从

    \[ L_{\text{BCE}} = -\bigl[y \log p + (1 - y)\log(1 - p)\bigr] \]

    出发,证明 \(\dfrac{\partial L}{\partial z} = p - y\)。

  • (b) 多分类情形:\(p_j = \text{softmax}(z)_j\),\(L = -\sum_j y_j \log p_j\),\(y\) 是 one-hot 编码。证明 \(\dfrac{\partial L}{\partial z_j} = p_j - y_j\)。

  • (c) 如果直接对概率 \(p\) 求导,会得到 \(\dfrac{\partial L}{\partial p} = \dfrac{p - y}{p(1-p)}\)。结合(a),说明为什么“输出 Logits + nn_bce_with_logits_loss()”比“先 Sigmoid 再算 BCE”在 float32 下更稳定。

  • (d) 对观测值 \(y = (1, 2, 3, 4, 100)\),分别求使 MSE 和 MAE 最小的常数预测 \(c\)。在房价预测这类含极端值的数据上,这两种选择意味着什么?

(a) 先对 \(p\) 求导:

\[ \frac{\partial L}{\partial p} = -\frac{y}{p} + \frac{1 - y}{1 - p} = \frac{-y(1-p) + (1-y)p}{p(1-p)} = \frac{p - y}{p(1-p)} \]

再乘上 Sigmoid 的导数 \(\dfrac{\partial p}{\partial z} = p(1-p)\):

\[ \frac{\partial L}{\partial z} = \frac{p - y}{p(1-p)} \cdot p(1-p) = p - y \]

那个危险的分母被精确约掉了。

(b) Softmax 的雅可比矩阵为

\[ \frac{\partial p_k}{\partial z_j} = p_k(\delta_{kj} - p_j) \]

其中 \(\delta_{kj}\) 在 \(k = j\) 时为 1,否则为 0。代入链式法则:

\[ \frac{\partial L}{\partial z_j} = -\sum_k \frac{y_k}{p_k} \cdot p_k(\delta_{kj} - p_j) = -\sum_k y_k \delta_{kj} + p_j \sum_k y_k = -y_j + p_j \]

最后一步用了 one-hot 的 \(\sum_k y_k = 1\)。结果与二分类完全同构:预测概率减去真实标签。

(c) 若先算 Sigmoid 再算 BCE,前向要把 \(p\) 存成 float32,反向要除以 \(p(1-p)\)。float32 的相对精度约为 \(\varepsilon \approx 1.19 \times 10^{-7}\),当 \(|z|\) 达到 17 左右时,\(\sigma(z)\) 已经被舍入为精确的 1(实测 \(\sigma(17)\) 在 float32 下就是 1),\(\sigma(20)\) 更是如此,\(1 - p = 0\)。

torch 的实现会把 BCE 里的对数截断在 \(-100\),所以“自信地犯错”时得到的不是 Inf,而是一个很大的常数 loss 和零梯度。取 \(y = 0\)、\(z = 20\) 实测:

# 概率版:先 sigmoid,再算 BCE
z1 <- torch_tensor(20, dtype = torch_float(), requires_grad = TRUE)
p  <- torch_sigmoid(z1)          # p = 1(float32 已饱和)
l1 <- nn_bce_loss()(p, torch_tensor(0, dtype = torch_float()))
l1$backward()
# loss = 100,z1$grad = 0

# Logits 版:直接喂原始输出
z2 <- torch_tensor(20, dtype = torch_float(), requires_grad = TRUE)
l2 <- nn_bce_with_logits_loss()(z2, torch_tensor(0, dtype = torch_float()))
l2$backward()
# loss = 20(即 log(1 + e^20)),z2$grad = 1

概率版把梯度截断成了 0:模型错得越离谱,越收不到纠正信号,只能永远停在错误答案上。这比“梯度爆炸”更准确地描述了框架里实际发生的故障——第 4 章正文说分母趋近 0 会导致梯度爆炸,那是数学式子在实数域上的行为;torch 用截断把它变成了梯度消失。

Logits 版给出 loss = 20、gradient = 1 = \(p - y\),方向完全正确。它在内部用 log-sum-exp 的恒等式直接计算 \(\log(1 + e^z)\) 这类量,把大指数限制在安全范围内。同一个数学函数,数值实现不同,稳定性就完全不同。这就是本书反复强调“输出 Logits、用 *_with_logits_loss()”的原因。

(d) MSE 的最小值点在均值处:

\[ \frac{d}{dc}\frac{1}{n}\sum_i (c - y_i)^2 = \frac{2}{n}\sum_i (c - y_i) = 0 \;\Longrightarrow\; c = \bar{y} = \frac{1+2+3+4+100}{5} = 22 \]

MAE 的最小值点在(任意一个)中位数处:

\[ \frac{d}{dc}\frac{1}{n}\sum_i |c - y_i| = \frac{1}{n}\sum_i \text{sign}(c - y_i) = 0 \;\Longrightarrow\; c = \text{median}(y) = 3 \]

一个 100 就把 MSE 的最优常数从 3 拉到了 22,而中位数完全不受影响。把两个常数互相代入对方的准则,差异更清楚:

\(c\) MSE MAE
22(均值) 1522 31.2
3(中位数) 1883 20.2

MSE 在自己的准则下赢了,MAE 在自己的准则下也赢了。“哪个常数更好”完全取决于你用哪个损失函数评价——这就是损失函数在定义“什么算好”。

放到房价场景里:MSE/RMSE 会努力去拟合少数豪宅,代价是普通住宅的预测被系统性抬高;MAE 则盯住“典型住房”。如果业务关心的是大多数购房者的误差,MAE 更合适;如果一笔大额交易的误差代价确实更高,MSE 的“平方惩罚”反而符合业务逻辑。选择损失函数,本质上是在声明“什么样的错误更不可接受”。

二、编程实践

练习 4:用 XOR 理解“非线性”

本练习复现第 4 章开头“交互效应”的实验,但把它改写成分类问题。

  • (a) 生成 \(n = 400\) 个均匀分布在 \([-1, 1]^2\) 上的点,标签按 XOR 规则设定:两个坐标同号记 1,异号记 0。把数据和标签转成 float32 张量。
  • (b) 用 nn_linear(2, 1) 加 nn_bce_with_logits_loss() 训练一个线性分类器(Adam,学习率 0.05,1000 轮),记录准确率。
  • (c) 把模型换成 nn_linear(2, 8) → nn_relu() → nn_linear(8, 1)(Adam,学习率 0.01,1000 轮),其他设置不变,记录准确率。
  • (d) 从几何角度解释(b)的上限:为什么一条直线无法把四个象限的标签分对?对连续均匀分布,最好的线性分类器准确率上限是多少?实测值为什么还会更低?
  • (e) 把隐藏单元数改为 4 和 16,观察准确率的变化,并与(d)的几何解释对应起来。

起始代码((b)部分):

library(torch)

set.seed(42)
n <- 400
xr <- matrix(runif(2 * n, -1, 1), ncol = 2)
yv <- as.numeric((xr[, 1] > 0) == (xr[, 2] > 0))  # 同号记 1

x <- torch_tensor(xr, dtype = torch_float())
y <- torch_tensor(matrix(yv, ncol = 1), dtype = torch_float())

loss_fn <- nn_bce_with_logits_loss()

(a)(b)(c) 完整代码与输出:

library(torch)

set.seed(42)
n <- 400
xr <- matrix(runif(2 * n, -1, 1), ncol = 2)
yv <- as.numeric((xr[, 1] > 0) == (xr[, 2] > 0))

x <- torch_tensor(xr, dtype = torch_float())
y <- torch_tensor(matrix(yv, ncol = 1), dtype = torch_float())

loss_fn <- nn_bce_with_logits_loss()

accuracy <- function(model) {
  model$eval()
  with_no_grad({
    prob <- torch_sigmoid(model(x))
    pred <- (prob > 0.5)$to(dtype = torch_float())
  })
  as.numeric((pred == y)$to(dtype = torch_float())$mean())
}

# 线性分类器
torch_manual_seed(42)
lin <- nn_sequential(nn_linear(2, 1))
opt <- optim_adam(lin$parameters, lr = 0.05)
for (t in 1:1000) {
  opt$zero_grad()
  loss <- loss_fn(lin(x), y)
  loss$backward()
  opt$step()
}
accuracy(lin)
# [1] 0.5575

# 单隐藏层 MLP
torch_manual_seed(42)
mlp <- nn_sequential(
  nn_linear(2, 8),
  nn_relu(),
  nn_linear(8, 1)
)
opt <- optim_adam(mlp$parameters, lr = 0.01)
for (t in 1:1000) {
  opt$zero_grad()
  loss <- loss_fn(mlp(x), y)
  loss$backward()
  opt$step()
}
accuracy(mlp)
# [1] 0.995

(d) 四个象限的标签是交替的:第一、三象限为 1,第二、四象限为 0。线性分类器的决策边界是一条直线,它把平面分成两个半平面。一个半平面无法同时包含第一、三象限而排除第二、四象限——因为前两者的凸包经过原点,也覆盖了后两者的一部分。

对连续均匀分布,半平面能达到的上限是 \(2/3\),而不是 75%。以斜率为 45° 的边界 \(x_1 + x_2 > 2/3\) 为例:第二、四象限里只有靠近角点 \((-1, 1)\)、\((1, -1)\) 的两个小三角形(各占该象限面积的 \(1/18\))会被误判为 1,其余全部判对;第一象限中满足 \(x_1 + x_2 > 2/3\) 的部分占 \(7/9\),判对;第三象限全部判 0,而它的标签是 1,全部判错。合计准确率 \[ \frac{7/9 + 17/18 + 17/18 + 0}{4} = \frac{2}{3}. \] (四个对称方向结果相同。)之所以不是 75%,是因为 75% 属于另一种数据:如果样本只有四个角点 \((\pm 1, \pm 1)\),线性分类器确实可以答对任意三个点,即 75%;但连续分布下大量样本落在象限内部,这条经验就失效了。

实测只有 55.75%,比 \(2/3\) 还低,原因在于交叉熵优化的目标和准确率并不一致。对严格对称的 XOR 数据,对数损失的最小值在 \(\mathbf{w} = 0\)(处处预测 0.5)处取得:记间隔 \(m = y \cdot z\),对称性保证 \(m\) 与 \(-m\) 等概率出现,而 \(\log(1 + e^{-m})\) 是凸函数,由 Jensen 不等式 \(E[\log(1 + e^{-m})] \ge \log 2\)。也就是说,任何非零的线性边界都会让一半样本的间隔变负,损失的增长超过另一侧的收益。优化器忠实最小化对数损失,于是主动“放弃分类”,准确率停在 50% 附近(55.75% 是有限步训练残留的不对称)。这正好说明:损失函数不等于评价指标。如果要追求准确率,应该在验证集上按准确率选模型或调整阈值,而不是盲信 Loss。

(e) 固定同样的种子,只改隐藏单元数:

模型 准确率
线性(无隐藏层) 0.5575
MLP,8 个隐藏单元 0.9950
MLP,4 个隐藏单元 0.9725
MLP,16 个隐藏单元 1.0000

4 个单元已经够用,而且可以给出一个精确构造。利用恒等式 \(|a| = \text{ReLU}(a) + \text{ReLU}(-a)\):

\[ |x_1 + x_2| - |x_1 - x_2| = \text{ReLU}(x_1 + x_2) + \text{ReLU}(-x_1 - x_2) - \text{ReLU}(x_1 - x_2) - \text{ReLU}(-x_1 + x_2) \]

这正好是 4 个隐藏单元(权重向量为 \((1,1)\)、\((-1,-1)\)、\((1,-1)\)、\((-1,1)\),偏置全为 0),输出层权重取 \((1, 1, -1, -1)\)。当 \(x_1, x_2\) 同号时该式为正,异号时为负,与 XOR 完全一致。两个隐藏单元则不够用:此时网络在平面上最多折出一条“脊”,凑不出四个象限交替的符号(实测准确率约 0.49)。

再增加单元只是让边界更平滑、训练更从容。这与练习 1(d)的结论一致——有非线性时,宽度是容量旋钮;但容量够用之后,继续加宽收益递减。

注意:计算准确率前必须调用 model$eval()。本练习没有 Dropout,但养成习惯;到了练习 5,这一步就是正确性的分水岭。

练习 5:把 nn_sequential 改写为 nn_module

第 4 章的 Ames 模型是用 nn_sequential 搭的。请把它改写成一个 nn_module 类,并加入 Dropout。Dropout 为什么能抑制过拟合、如何调 p,会在第 6 章展开;本题只关注 nn_module 的重构,以及训练/评估模式切换带来的工程后果。

  • (a) 写出 ames_mlp 类:输入层 → 128 → ReLU → 64 → ReLU → 1。解释为什么 forward 里可以直接写 torch_relu(),而不需要像 nn_sequential 那样放一个 nn_relu()。
  • (b) 在两个隐藏层之后各加一个 nn_dropout(p = 0.2),说明参数 p 的含义,以及训练模式与评估模式下这一层的行为差异。
  • (c) 训练完成后,在测试集上分别用 model$train() 和 model$eval() 计算 RMSE,各重复 3 次。解释结果的差异。
  • (d) 为什么回归任务的最后一层保持线性、不加任何激活函数?

(a) 类定义如下:

ames_mlp <- nn_module(
  classname = "ames_mlp",

  initialize = function(in_dim, h1 = 128, h2 = 64, p = 0.2) {
    self$fc1  <- nn_linear(in_dim, h1)
    self$fc2  <- nn_linear(h1, h2)
    self$fc3  <- nn_linear(h2, 1)
    self$drop <- nn_dropout(p)
  },

  forward = function(x) {
    x %>%
      self$fc1() %>% torch_relu() %>% self$drop() %>%
      self$fc2() %>% torch_relu() %>% self$drop() %>%
      self$fc3()
  }
)

model <- ames_mlp(ncol(x_train), p = 0.2)

nn_sequential 要求容器里的每一块都是 Module 对象,所以必须写 nn_relu();nn_module 的 forward 是你自己写的普通函数,直接调用函数式的 torch_relu(x) 最自然。如果愿意,也可以把 self$relu <- nn_relu() 放进 initialize,再在 forward 里调用 self$relu(),两种写法等价。第 4 章的脚注已经解释过这一点:模块式 API 存在的意义主要是适配 nn_sequential 这类容器。

(b) p = 0.2 是丢弃概率:训练时每个元素以 0.2 的概率被置零,保留下来的元素按 \(\frac{1}{1-p} = 1.25\) 放大。这种“训练时放大、测试时不动”的做法叫 inverted dropout,它的好处是评估阶段不需要做任何缩放。

实测(nn_dropout(0.2),输入全为 1):

set.seed(1)
m <- nn_dropout(0.2)
x <- torch_ones(10)

m$train(); as.numeric(m(x))
# [1] 1.25 1.25 1.25 1.25 0.00 1.25 1.25 1.25 1.25 1.25
#                             ^ 被丢弃
# 保留元素为 1/(1-0.2) = 1.25

m$eval(); as.numeric(m(x))
# [1] 1 1 1 1 1 1 1 1 1 1

评估模式下 Dropout 是一个恒等映射。nn_sequential 和 nn_module 都继承了 $train() / $eval() 方法,它们会递归地切换所有子模块的状态:eval() 等价于 train(FALSE),train(TRUE) 则把 self$training 置真并递归到子模块。

(c) 用同一个已经训练好的模型比较:

rmse <- function(model, mode = c("eval", "train")) {
  mode <- match.arg(mode)
  if (mode == "eval") model$eval() else model$train()
  with_no_grad({
    pred <- as.numeric(model(x_test))
  })
  sqrt(mean((as.numeric(y_test) - pred)^2))
}

replicate(3, rmse(model, "eval"))
# 三次结果完全相同(模型固定、Dropout 关闭)

replicate(3, rmse(model, "train"))
# 三次结果互不相同,通常不低于 eval 模式

训练模式下每次前向都会随机丢弃不同的神经元,预测值因此带有随机性;三次结果自然不同,而且整体方差更大。即使某一次碰巧比 eval 模式更好,也不能把这个“运气”当成模型能力——部署时你无法控制 dropout 的随机数。

所以评估和推理前必须调用 model$eval(),训练开始前再调用 model$train()(或用 luz 的 fit 流程,它会自动处理)。这个习惯在有 BatchNorm 时更加关键,因为 BatchNorm 在两种模式下连统计量的用法都不一样。

(d) 回归的目标是任意实数(房价可以是 30 万,也可以是 3000 万),输出层必须保持线性,值域不受限制。

如果最后加 Sigmoid,输出被压到 \((0, 1)\),模型只能预测这个区间的数;而且 Sigmoid 在两端饱和,梯度趋近于 0,大误差反而学不动。如果加 ReLU,输出非负,负值目标永远学不到,还会出现和“死亡 ReLU”同源的梯度问题。所以最后一层不加激活,让损失函数直接在实数域上工作。分类任务则相反:输出 Logits,由带 Logits 的损失函数在内部完成 Sigmoid/Softmax,理由见练习 3(c)。

练习 6:弹性网的 \(\lambda\)–\(\alpha\) 扫描与软阈值

复用第 4 章 code/4_ames_en.R 前半段的数据管线(x_train、y_train、x_test、y_test、n_in 都已准备好),完成下面的实验。

  • (a) 在 \(\lambda \in \{0.1, 0.5, 1.0\}\)、\(\alpha \in \{0, 0.5, 0.95\}\) 的网格上各训练一次弹性网,记录被精确清零的特征数和测试集 RMSE,整理成表格。
  • (b) 解释 \(\lambda\) 和 \(\alpha\) 分别控制什么。为什么 \(\alpha = 0\) 时一个特征都不会被清零?
  • (c) 代码里的 tau <- learning_rate * lambda * alpha 是在什么假设下成立的?如果把 optim_adam 换成 optim_sgd,它还严格成立吗?在 Adam 下它对应什么?
  • (d) 如果希望软阈值的强度不随学习率变化,应该怎么改?

(a) 把第 4 章的训练循环包装成函数,逐个网格点运行:

loss_fn <- nn_mse_loss()

run_en <- function(lambda, alpha, epochs = 1000, lr = 0.002) {
  torch_manual_seed(123)
  w <- (torch_randn(n_in, 1) * 0.1)$requires_grad_(TRUE)
  b <- torch_zeros(1, requires_grad = TRUE)
  opt <- optim_adam(list(w, b), lr = lr)

  for (t in 1:epochs) {
    opt$zero_grad()
    pred <- torch_mm(x_train, w) + b
    loss <- loss_fn(pred, y_train) +
      lambda * (1 - alpha) * 0.5 * torch_square(w)$sum()
    loss$backward()
    opt$step()

    with_no_grad({
      tau <- lr * lambda * alpha
      w$copy_(torch_sign(w) * torch_relu(torch_abs(w) - tau))
    })
  }

  with_no_grad({
    pred <- torch_mm(x_test, w) + b
    rmse <- as.numeric(torch_sqrt(torch_mean(torch_square(pred - y_test))))
    zero <- as.numeric((torch_abs(w) == 0)$sum())
  })
  tibble::tibble(zero = zero, rmse = rmse)
}

grid <- expand.grid(lambda = c(0.1, 0.5, 1.0), alpha = c(0, 0.5, 0.95))
res <- purrr::pmap_dfr(grid, function(lambda, alpha) {
  run_en(lambda, alpha) %>%
    dplyr::mutate(lambda = lambda, alpha = alpha, .before = 1)
})

9 次训练在普通笔记本上通常需要一到两分钟。按第 4 章的切分(95 个特征)实测,结果如下:

\(\lambda\) \(\alpha\) 清零特征数 测试 RMSE
0.1 0.00 0 0.3861
0.5 0.00 0 0.3890
1.0 0.00 0 0.3975
0.1 0.50 8 0.3859
0.5 0.50 22 0.3898
1.0 0.50 25 0.4085
0.1 0.95 12 0.3864
0.5 0.95 31 0.3944
1.0 0.95 48 0.5969

两个趋势很清楚:

  • 清零的特征数随 \(\lambda\) 增大而增加,也随 \(\alpha\) 增大而增加;\(\alpha = 0\) 那一列恒为 0;
  • RMSE 不是单调的。\(\lambda\) 较小时,正则化的收益与损害大致抵消,RMSE 与基线接近;\(\lambda\) 过大时(尤其 \(\alpha = 0.95\)、\(\lambda = 1\)),模型被压得太狠,RMSE 明显恶化(0.5969)。

清零数对随机种子和初始化很敏感:本章正文报告的是 20 个清零、RMSE 0.3974,这里复现得到 31 个,差异来自 RNG 状态不同(正文在跑弹性网之前已经训练过 MLP,随机数流被消耗过)。这种敏感性本身也是(c)的旁证:在 Adam 下 \(\tau\) 只是一个启发式旋钮,而不是精确的近端阈值。

(b) \(\lambda\) 是总体惩罚强度,它同时放大 L1 和 L2 两部分;\(\alpha \in [0, 1]\) 是惩罚的配比,\(\alpha\) 越大越偏向 L1。

\(\alpha = 0\) 时目标函数退化为

\[ L_{\text{Ridge}} = \text{MSE} + \frac{\lambda}{2}\|\mathbf{w}\|_2^2 \]

L2 的梯度是 \(-\lambda \mathbf{w}\),它在 \(w = 0\) 处为零,只会把权重按比例拉向原点,不会产生精确的零解。软阈值步骤的阈值 \(\tau = lr \cdot \lambda \cdot \alpha\) 也变为 0,自然一个都清不掉。只有当 \(\alpha > 0\)、L1 项存在时,软阈值才会把绝对值小于 \(\tau\) 的权重视为“不值得保留”,直接归零。把 \(\alpha\) 取到 1 就是纯 Lasso,清零数会进一步上升;取 0.95 则是“以 L1 为主、留一点 L2 稳定梯度”的折中,也就是本章的设定。

(c) 这个阈值来自近端梯度下降(proximal gradient)的推导。考虑

\[ F(w) = f(w) + \lambda_1 \|w\|_1 \]

其中 \(f\) 是光滑的损失(MSE 加 L2)。近端梯度法先走一步梯度下降 \(w \leftarrow w - lr \cdot \nabla f(w)\),再对 L1 部分做近端算子。而 L1 的近端算子就是软阈值,阈值为 \(lr \cdot \lambda_1\)。在本章代码里 \(\lambda_1 = \lambda \alpha\),所以

\[ \tau = lr \cdot \lambda \cdot \alpha \]

在“梯度步的步长恰好是 \(lr\)”这个前提下,它是精确的,用 optim_sgd 时正是如此。

换成 Adam 后这个前提不再成立:Adam 的更新量是 \(\hat{m}/\sqrt{\hat{v}}\) 再乘 \(lr\),每个坐标的有效位移并不等于 \(lr \cdot \nabla f\),方向和尺度都被重新归一化过。因此 \(\tau\) 仍然是一个可用的“收缩强度”旋钮,但它不再是近端算子的精确阈值,也不等于 \(\lambda \alpha\)。想要精确,就得回到 SGD(或专门的近端优化器)。

(d) 有三种常见做法:

  1. 用 optim_sgd 做优化,\(\tau = lr \cdot \lambda \cdot \alpha\) 自然成立;
  2. 把 \(\tau\) 从学习率中解耦出来,直接当作独立超参数(例如每轮固定 \(\tau = 0.01\)),这样调整学习率时不会顺带改变正则化强度;
  3. 改用 AdamW 这类把权重衰减与梯度更新解耦的优化器,用它的 weight_decay 表达 L2 部分,再单独处理 L1。

第 1 种最接近本章的数学推导,第 2 种在工程上最省心。无论哪种,都要注意:本章代码把 L2 项写进了 loss、把 L1 项放在优化器之外,这是“手工近端梯度”的教学写法;真实项目里更常见的是直接调用正则化库或 AdamW。

还有一个容易被忽略的量纲问题:本章的 MSE 是均值。如果改成对 batch 求和,数据项会被放大 \(B\) 倍,正则项的相对权重随之变弱;要保持同样的正则强度,\(\lambda\) 需要乘以 batch size \(B\),\(\tau\) 也跟着变。写正则化代码时,先确认损失是均值还是求和,再确定 \(\lambda\) 的量级。

三、开放思考

练习 7:MLP 为什么只比 lm 好一点?

第 4 章的 Ames 实验里,MLP 的测试集 RMSE 为 0.3687,基准线性模型 lm 为 0.3914(都是标准化尺度),提升约 6%。请回答:

  • (a) 结合数据管线(对数变换、独热编码、标准化)和样本规模,解释为什么提升幅度如此有限。
  • (b) 设计一个实验来检验“MLP 的优势来自交互项”这一假说。提示:先构造一个只有主效应的合成数据,再构造一个含 \(x_1 \times x_2\) 的合成数据,分别在两个数据上比较 lm 与 MLP。
  • (c) 一张 \(28 \times 28\) 的图像有 784 个像素。两两交互项共有多少个?如果只做二阶扩展,特征维度会变成多少?三阶呢?这说明了什么?
  • (d) 在你的业务数据上,你会优先选 MLP,还是正则化线性模型/树模型?列出 3 个判断依据。

(a) 主要有四个原因。

第一,管线已经把大部分“非线性”做完了。Sale_Price 取了 Log10、做了标准化,Gr_Liv_Area 和 Lot_Area 也取了 Log10;这些变换把原本的乘性关系和长尾压缩成了近似加性的关系。独热编码又把类别变量展成一组 0/1 主效应。做完这些,线性模型面对的数据已经相当“线性”。

第二,样本量不大。训练集只有 2344 行,特征约 95~97 维(含独热;取决于是否同时剔除经纬度列),信噪比不高。MLP 的额外容量没有足够的数据去估计,容易把噪声也拟合进去。

第三,Ames 数据里的交互本来就少。房屋价格主要由面积、质量、年份、社区等少数主效应决定,真正强的交互项(例如“高质量 × 大面积”)数量有限。MLP 学到的额外结构,大部分只是在重复线性模型已经捕捉到的东西。

第四,深度模型对初始化、学习率、轮数都更敏感。6% 的提升是在“参数没有任何优化”的前提下得到的(第 4 章原文),调参后差距可能缩小,也可能扩大,但量级不会变。

这也是表格数据领域的普遍现象:在中小规模、以数值和类别特征为主的问题上,正则化线性模型和梯度提升树往往不输深度网络,而且训练更快、更容易解释。

(b) 一个可操作的实验设计:

  1. 固定 \(n = 2000\)、\(p = 5\),生成 \(x_{ij} \sim \text{Uniform}(-1, 1)\)。
  2. 数据 A(只有主效应):\(y = 2x_1 + 3x_2 - x_3 + \varepsilon\)。
  3. 数据 B(含交互):\(y = 5 x_1 x_2 + 2x_3 + \varepsilon\)。
  4. 用同一套切分、同样的标准化,分别拟合 lm(y ~ .) 和结构相同的 MLP。
  5. 比较测试集 RMSE(或 \(R^2\))。

预期结果:在数据 A 上两者接近,MLP 不会更好(甚至因为方差更大而略差);在数据 B 上 lm 明显失败,MLP 明显更好。如果实验结果符合这个模式,“MLP 的优势来自交互项”就得到了支持。

这个设计还可以进一步控制变量:改变 \(n\)、改变噪声水平 \(\varepsilon\)、改变交互项的强度,看 MLP 的优势在什么条件下出现、在什么条件下消失。

(c) 两两组合数:

\[ \binom{784}{2} = \frac{784 \times 783}{2} = 306{,}936 \]

加上 784 个主效应和一个截距,二阶扩展后的特征维度约为 \(784 + 306{,}936 + 1 = 307{,}721\)。

三阶组合数:

\[ \binom{784}{3} = \frac{784 \times 783 \times 782}{6} = 80{,}007{,}984 \]

仅三阶就超过 8000 万维。这说明了为什么“手工构造交互项”在高维输入上不可行:维度随阶数组合爆炸,样本量根本撑不起参数估计,计算和存储也无法承受。

不必等到 784 维,中等维度就已经开始失效。回到本章的 Ames 数据:95 个特征的两两交互有

\[ \binom{95}{2} = \frac{95 \times 94}{2} = 4{,}465 \]

个,加上 95 个主效应约 4,560 列,而训练集只有 2,344 行——普通最小二乘连方程都不够解,必须依赖正则化,而且估计方差极大、几乎不可解释。这就是“手工造交互”在几百维以内就会碰到天花板的定量依据。

MLP 的价值就在这里:它不显式枚举交互项,而是用共享的权重和逐层的非线性变换,让隐藏单元自动组合出有用的交互。参数量的增长是线性的(每层 \(in \times out\)),而不是组合级的。换句话说,MLP 用“深度 + 宽度”换取了交互项的隐式表示。

(d) 可以从这几个维度判断(答出任意 3 条即可):

  1. 样本量与特征维度:样本少、特征以数值/类别为主时,优先线性模型或树模型;样本达到十万级以上、且存在明显的非线性交互时,MLP 才开始占优。
  2. 特征类型:表格数据的主效应强、交互弱,树模型通常最稳;图像、文本、序列这类“感知型”数据,深度模型几乎是唯一选择。
  3. 可解释性与合规要求:线性模型的系数可以直接读,弹性网还能做特征选择;如果业务需要向监管或客户解释每一个变量的方向,深度模型的解释成本很高(通常要借助第 15 章的可解释性工具)。
  4. 部署与推理成本:线性模型可以在数据库里用 SQL 打分,树模型有成熟的编译方案;MLP 需要额外的运行时(torch、ONNX 等),延迟和运维成本更高。
  5. 基线强度:先跑一个正则化线性模型或 LightGBM 作为基线,再决定是否值得上深度模型。第 4 章的 6% 提升就是很好的例子:如果业务对 6% 不敏感,就没有必要承担额外的复杂度。

没有普适的答案。重要的是先建立强基线,再用实验回答“额外的复杂度买到了什么”。

四、纠错与陷阱

练习 8:梯度清零与更新时机

  • (a) 下面这段训练循环漏了一个关键步骤,导致梯度不断累加。请指出问题、说明症状,并给出修正。

    for (t in 1:epochs) {
      y_pred <- model(x_train)
      loss <- loss_fn(y_pred, y_train)
      loss$backward()
      optimizer$step()
    }
  • (b) 如果把 optimizer$zero_grad() 放在 backward() 之后、step() 之前,会发生什么?放在 step() 之后呢?

(a) 问题在于全程没有调用 optimizer$zero_grad()。torch 的梯度默认是累加的(grad 属性只增不减),所以第 \(t\) 轮 backward() 之后,参数上挂着的是前 \(t\) 轮梯度之和,而不是当前这一轮的梯度。

症状:用 SGD 时,等效学习率随迭代次数不断增大,Loss 很快震荡甚至发散;用 Adam 时自适应缩放会掩盖一部分症状,训练看起来还能收敛,但优化目标已经被悄悄改掉,结果不可信、也不可复现。第 3 章“梯度的三部曲”专门讲过这个陷阱。

修正只需在 backward() 之前清零:

for (t in 1:epochs) {
  optimizer$zero_grad()          # 清空上一轮残留的梯度
  y_pred <- model(x_train)
  loss <- loss_fn(y_pred, y_train)
  loss$backward()
  optimizer$step()
}

(b) 放在 backward() 之后、step() 之前:zero_grad() 会把刚算出来的梯度清零,step() 拿到的是一组零梯度,参数完全不更新。症状是 Loss 从第一轮开始就一动不动——这是最容易误判为“学习率太小”的 bug。

放在 step() 之后:在简单的单步循环里,它等价于在下一轮开头清零,训练可以正常工作。但这种写法比较脆弱:一旦循环里出现条件分支、梯度累积、多次 step() 或者中途提前退出,就很容易漏清或错清。约定俗成的写法是把 zero_grad() 放在每轮最前面,让“清零 → 前向 → 反向 → 更新”成为一个固定的四拍节奏。