稀疏自编码器在损失里加一项:惩罚「隐层平均激活率太高」。 不加惩罚的时候,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 秒实验
- 把
beta加到 0.5 再跑。 16 个单元会几乎全部关闭,重构误差冲到 0.15 以上——过度稀疏等于自废武功。 - 把
beta从 L1 改成 KL 散度(目标是 rho 等于 0.05),比较两者的激活率曲线。 KL 能把激活率精确压到目标值附近,L1 只能给一个大概的下压方向。 - 把
epochs从 1200 加到 2400,看死亡单元数怎么变。 有些单元会「晚死」——训练早期还活着,后期被压死。
常见误区
- 「稀疏就是让隐层大部分单元输出 0。」 严格说,稀疏约束的是「在训练集上的平均激活率」,不是「每一张图上大部分为 0」。 一张图可能点亮 5 个单元,另一张点亮另外 5 个,平均下来只有 5% 亮着。 这才是稀疏想要的效果:不同输入用不同的少数单元。
- 「稀疏一定提升可解释性。」 实测挑食指数反而下降。稀疏保证的是「同时亮的少」,不保证「每个单元有明确含义」。 可解释性还要看数据本身有没有可分离的概念——数据里没有,稀疏也变不出来。
- 「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 那样跑得动。
- ☐ (本节完,下一节 5.4 栈式自编码器)