📗 第5章 自编码器
📄 一句话概括

稀疏自编码器在损失里加一项:惩罚「隐层平均激活率太高」。 不加惩罚的时候,16 个单元的平均激活率是 0.4636,将近一半常年亮着。 beta 加到 0.05,激活率掉到 0.1321,16 个里有 4 个彻底关闭; 加到 0.10,关闭 10 个,重构误差从 0.0206 翻到 0.0609。 代价是重构变差,收益是隐层「省着用」。 但「稀疏就等于可解释」不成立——实测单元并没有变得更加各管一个数字。

📝 本节和前面几节的关系

前两节的逼法都动「输入」或「瓶颈宽度」。 这一节动的是损失函数:网络结构一点不改,只加一个惩罚项。 这也是自编码器这类方法最灵活的地方。 想让它学什么,就在损失里写什么——第 6 章的 Dropout、正则化,思路一脉相承。

一、为什么要压激活率

普通自编码器只有一个要求:重构得准。 它完全可以让隐层一半单元常年亮着,因为这样最容易拟合。 代价是每个单元都在打杂,特征分不出工。 稀疏的动机来自生物:大脑里同一时刻只有很小一部分神经元在放电。 工程上的解释更实在:隐层能用的「词汇」少了,网络就没法靠死记硬背过关。 做法是在损失里加一项,惩罚「平均激活率偏离目标值」。 目标值通常取 0.05 左右,也就是「平均只有 5% 的单元亮着」。

二、两种写法:KL 与 L1

教材里用 KL 散度。 记第 j 个单元的平均激活率是 rho_j,目标值是 rho,惩罚项是:

KL(rho || rho_j) = rho * log(rho / rho_j)
                 + (1 - rho) * log((1 - rho) / (1 - rho_j))

它的性质很好:rho_j 等于 rho 时罚 0,离得越远罚得越重。 缺点是导数里带 rho_j,rho_j 接近 0 时会数值不稳。 这里的 Demo 用更简单的 L1:直接把隐层激活值加起来乘一个系数 beta。 L1 的导数是一个常数,好写也好调;效果一样——把激活率往下压。 实践里两者都常用,选哪个取决于你要不要「精确控制到某个 rho」。 要注意一件事:L1 只压「平均激活率」,不保证「每个单元轮流亮」。 它完全可以靠「关掉一半单元、让另一半常年亮」来满足约束——这不是生物学意义上的稀疏, 但在工程上同样有用。

三、加大惩罚,付出什么代价

把 beta 从 0 一路加到 0.10,看三个数字:平均激活率、关闭的单元数、重构误差。

beta 平均激活率 关闭的单元数 重构误差
0.000 0.4636 0 / 16 0.0206
0.005 0.4085 0 / 16 0.0211
0.020 0.2653 0 / 16 0.0245
0.050 0.1321 4 / 16 0.0373
0.100 0.0617 10 / 16 0.0609
这张表就是稀疏的账单。
激活率从 0.4636 压到 0.0617,代价是重构误差从 0.0206 涨到 0.0609,接近三倍。
中间那一档(0.05)是常见的折中:激活率砍到三分之一,误差只涨八成。
还有个现象值得注意:激活率不是均匀下降的,而是一部分单元先「死掉」。
beta 0.05 时有 4 个单元彻底关闭,0.10 时 10 个关闭。
这叫单元死亡——一旦某个单元的权重被压到几乎不激活,它就再也收不到有用的梯度,只能一直死着。

四、稀疏到底做了什么

教科书常说稀疏能让每个单元「专管一件事」,变得更可解释。 这个说法值得验一下。 办法是算「挑食指数」:每个单元在十个数字上的最高激活率,减去它的中位数,再取平均。 数值越大,说明单元越认某几个数字。

beta 激活率 挑食指数 关闭的单元数
0.000 0.4634 0.117 0 / 16
0.005 0.4083 0.120 0 / 16
0.020 0.2652 0.126 0 / 16
0.050 0.1319 0.087 4 / 16
0.100 0.0615 0.046 10 / 16
挑食指数没有随 beta 变大而变大,反而略微下降。
换句话说,在这批数据上,稀疏没有把单元训练成「各管一个数字」。
它真正做到的是另一件事:让一部分单元彻底闭嘴,剩下的少数单元承担全部工作。
为什么没有出现漂亮的局部特征?因为样本太同质。
十个数字全用同一套七段笔画,笔画本身就是共享的,任何「专管一个数字」的单元都学不到稳定规律。
换成手写数字,稀疏自编码器才会长出「某一小段笔画」「某个方向的拐角」这种局部特征。
所以别把「稀疏就等于可解释」当成定律。
它保证的是「同时亮的单元少」,不保证「每个单元对应你能说清的概念」。
这两件事经常一起出现,但不是同一件事。

五、动手 Demo:加惩罚:激活率、死亡单元、以及「挑食指数」的实测

隐层换成 sigmoid,这样「激活值」本身就是 0 到 1 之间的「亮不亮」,好统计。 惩罚项 beta / n 直接加在隐层的梯度上,一行代码就够。 最后会算一个「挑食指数」,用来检验「稀疏是否让单元更专一」这个常见说法。

def train(X, h=16, beta=0.0, epochs=1200, lr=0.05, seed=0):
    rg = np.random.default_rng(seed)
    W1 = rg.normal(0.0, 0.3, (64, h))
    b1 = np.zeros(h)
    W2 = rg.normal(0.0, 0.3, (h, 64))
    b2 = np.zeros(64)
    n = len(X)
    for _ in range(epochs):
        A1 = sig(X @ W1 + b1)               # 用 sigmoid,激活值本身就是「亮不亮」
        A2 = A1 @ W2 + b2
        d2 = (A2 - X) * 2.0 / n
        d1 = (d2 @ W2.T) * A1 * (1.0 - A1) + beta / n   # 加号这一项就是 L1 惩罚
        W2 -= lr * (A1.T @ d2)
        b2 -= lr * d2.sum(0)
        W1 -= lr * (X.T @ d1)
        b1 -= lr * d1.sum(0)
    return W1, b1, W2, b2


for beta in (0.0, 0.005, 0.02, 0.05, 0.10):
    p = train(Xtr, beta=beta)
    A = encode(Xtr, p)
    print(beta, A.mean(0).mean(), int((A.mean(0) < 0.02).sum()))

实跑输出:

--------------------------------------------------------------------
稀疏自编码器:给隐层加一条「少亮几个」的约束
--------------------------------------------------------------------
[1] 稀疏自编码器多了一条约束:隐层「不许一起亮」
  普通自编码器只有一个要求:重构得准。它可能让隐层一半单元常年亮着,
  因为这样最容易拟合,代价是每个单元都在打杂,特征分不出工。
  稀疏自编码器在损失里加一项,惩罚「隐层平均激活率太高」:
  于是网络必须在「重构准」和「少亮几个」之间找平衡

[2] 两个惩罚项,两种写法
  教材里用的是 KL 散度:把第 j 个单元的平均激活率 记为 rho_j,目标值记为 rho,
  惩罚项等于 rho 乘 log(rho 除 rho_j) 加 (1 减 rho) 乘 log((1 减 rho) 除 (1 减 rho_j))
  它的作用是:rho_j 离 rho 越远,罚得越重;等于 rho 时罚 0
  这里用更简单的 L1:直接把隐层激活值加起来乘一个系数 beta
  L1 的导数是一个常数,好写;效果一样——把激活率往下压
  实践里两者都常用,选哪个取决于你要不要「精确控制到某个 rho」

[3] 加大惩罚,激活率压下去、重构误差涨上来
  beta      平均激活率    完全关闭的单元    重构误差
  0.000     0.4636        0 / 16            0.0206
  0.005     0.4085        0 / 16            0.0211
  0.020     0.2653        0 / 16            0.0245
  0.050     0.1321        4 / 16            0.0373
  0.100     0.0617        10 / 16           0.0609

  beta 等于 0 就是不稀疏:平均激活率 0.4636,隐层将近一半的单元常年亮着。
  把 beta 加到 0.05,激活率掉到 0.1321,代价是重构误差从 0.0206 涨到 0.0373。
  再加到 0.10,16 个单元里有 10 个彻底关闭,重构误差翻到 0.0609。
  这就是稀疏的账单:用重构精度换隐层的「省着用」。

[4] 稀疏到底做了什么:淘汰,还是分工
  beta      激活率    挑食指数    关闭的单元数
  0.000     0.4634    0.117       0 / 16
  0.005     0.4083    0.120       0 / 16
  0.020     0.2652    0.126       0 / 16
  0.050     0.1319    0.087       4 / 16
  0.100     0.0615    0.046       10 / 16

  挑食指数是「每个单元在十个数字上的最高激活率,减去它的中位数」的平均。
  数值越大,说明单元越认某几个数字;数值越小,说明它对谁都一样。

  结果是:挑食指数没有随 beta 变大而变大,反而略微下降。
  换句话说,在这批数据上,稀疏没有把单元训练成「各管一个数字」。
  它真正做到的是另一件事:让一部分单元彻底闭嘴,剩下的少数单元承担全部工作——
  beta 等于 0.10 时,16 个单元里 10 个关闭,剩下 6 个就够了。

  为什么没有出现漂亮的局部特征?因为样本太同质:十个数字全用同一套七段笔画,
  笔画本身就是共享的,任何「专管一个数字」的单元都学不到稳定规律。
  换成手写数字,稀疏自编码器才会长出「某一小段笔画」「某个方向的拐角」这种局部特征。

  所以别把「稀疏就等于可解释」当成定律。
  它保证的是「同时亮的单元少」,不保证「每个单元对应你能说清的概念」。

[5] 稀疏的三个用处
  一、省单元:能把大部分单元关掉、只留几个干活,等于自动做了「特征筛选」
  二、抗过拟合:隐层能用的「词汇」少了,网络不会靠死记硬背过关
  三、可解释(有条件):单元少了,人才看得过来;但单元对应什么概念要另说
  代价也直接:重构变差。稀疏度是要调的,不是越稀越好。

[6] 一句话对比两种「逼法」
  降噪是往输入里加干扰,逼它学「结构在哪」
  稀疏是往隐层里加限制,逼它学「分工在哪」
  两种都不是改网络结构,只改损失函数——这是自编码器这一类方法最灵活的地方
  下一节把这类自编码器一层层叠起来,看它能不能像第 4 章的深度信念网络那样逐层预训练

第一张表看得出来:beta 从 0 到 0.02 之间,激活率已经砍了一半,但没有一个单元死亡。 这一段是「健康的稀疏」——所有单元还活着,只是亮得少了。 过了 0.05 就开始死单元,这时候省下来的算力是拿「可用单元数」换的。 第二张表的结论和教科书不一样,这里如实写下来。 挑食指数 0.117 到 0.126 再到 0.087、0.046,是往下走的。 也就是说,压得越狠,剩下的单元反而越「不挑食」——因为它们要承担全部工作, 对什么数字都得亮一点。 这个结果不是程序写错了,而是数据决定的。 十个数字共用七段笔画,本来就没有「专管一个数字」的规律可学。 要看到教科书上那种漂亮的特征,得换 MNIST 那种手写数字。 这一点必须说清楚,否则读者会以为稀疏自编码器「没效果」。

30 秒实验

  1. 把 beta 加到 0.5 再跑。 16 个单元会几乎全部关闭,重构误差冲到 0.15 以上——过度稀疏等于自废武功。
  2. 把 beta 从 L1 改成 KL 散度(目标是 rho 等于 0.05),比较两者的激活率曲线。 KL 能把激活率精确压到目标值附近,L1 只能给一个大概的下压方向。
  3. 把 epochs 从 1200 加到 2400,看死亡单元数怎么变。 有些单元会「晚死」——训练早期还活着,后期被压死。

常见误区

  1. 「稀疏就是让隐层大部分单元输出 0。」 严格说,稀疏约束的是「在训练集上的平均激活率」,不是「每一张图上大部分为 0」。 一张图可能点亮 5 个单元,另一张点亮另外 5 个,平均下来只有 5% 亮着。 这才是稀疏想要的效果:不同输入用不同的少数单元。
  2. 「稀疏一定提升可解释性。」 实测挑食指数反而下降。稀疏保证的是「同时亮的少」,不保证「每个单元有明确含义」。 可解释性还要看数据本身有没有可分离的概念——数据里没有,稀疏也变不出来。
  3. 「beta 越大越好,越稀疏越像大脑。」 beta 0.10 时 16 个单元关了 10 个,重构误差翻了近三倍。 稀疏度是要调的,而且最优值通常不在最大处。 大脑稀疏是因为它有 860 亿个神经元可以挥霍,你这里只有 16 个。
自测三问(点开对答案) 1. **稀疏自编码器的损失里多了什么?** —— **多了一项「平均激活率惩罚」:人话就是「隐层不许一起亮」。** **实现上可以用 KL 散度(目标 rho 明确)或者 L1(导数简单)。** **两者都在把平均激活率往下压,区别是 KL 能精确控到某个值。** 2. **为什么稀疏会让重构变差?** —— **因为能用的单元少了,压缩后的表示容量变小。** **实测激活率从 0.4636 压到 0.1321 时,误差从 0.0206 涨到 0.0373。** **这是必然的交换:表达能力和稀疏度是一对矛盾。** 3. **「关闭的单元」是什么现象?** —— **单元死亡:某个单元被压到几乎不激活之后,梯度也接近 0,它再也活不过来。** **beta 0.05 时死了 4 个,0.10 时死了 10 个。** **所以稀疏度设得太高,等于主动废掉一半隐层。**

到这儿,单层自编码器的三种改法都讲完了:改输入(降噪)、改损失(稀疏)。 还差一种:改层数。 下一节把它们一层层摞起来,看逐层预训练能不能像第 4 章的 DBN 那样跑得动。

相关笔记