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

降噪自编码器只改一处:喂进去之前把输入按概率打翻几个像素,目标仍然是干净的图。 于是「原样搬运」这条路被堵死——搬过去就是错的。 实测坏 30% 的输入上,干净训练的模型误差 0.1583,降噪训练的是 0.1150,差三成。 而在干净输入上两者几乎持平,干净训练那个还略好。 噪声强度有最优值:0.10 到 0.20 之间最好,太小没学到纠错,太大连原图都看不清。

📝 本节和前面几节的关系

上一节的自编码器靠「瓶颈窄」逼它筛选。 但瓶颈一旦放宽,它就会退化成恒等映射。 这一节换一个完全不依赖瓶颈的逼法:改训练对。 这也是第 5 章的第一个「换个目标函数」的例子。 后面两节(稀疏、栈式)都是同一个套路的变体——想让它学什么,就改损失去逼它。

一、只改一行代码

普通自编码器的训练对是「输入 X,目标 X」。 降噪自编码器的训练对是「输入 X 加噪声,目标 X」。

p_clean = train_ae(Xtr, Xtr)                    # 普通
p_noise = train_ae(corrupt(Xtr, 0.15), Xtr)     # 降噪

corrupt 做的事情只有一件:按概率逐像素翻转。 rate 等于 0.15 就是「平均打坏 15% 的像素」。 网络每次看到的输入都不一样,但目标始终是那张干净的图。 这个改动看着微不足道,效果却是本质的:网络再也无法「把输入搬过去」。 它必须做一件更难的事——从坏掉的数据里推断「本来应该是什么样」。

二、拿脏图试试看

练好两个模型,然后用不同损坏程度的图去试,看谁还原得干净。 误差是「重构结果」与「干净原图」之间的平方差,越小越好。

测试输入的损坏程度 干净训练的重构误差 降噪训练的重构误差
干净输入 0.0180 0.0312
坏 5% 0.0331 0.0368
坏 10% 0.0527 0.0438
坏 20% 0.0984 0.0687
坏 30% 0.1583 0.1150
干净输入上,干净训练那个更好(0.0180 对 0.0312)——这很正常,在它擅长的场地里它不该输。
输入一脏,优势立刻反转:坏 20% 时 0.0687 对 0.0984,坏 30% 时 0.1150 对 0.1583。
这就是交易:用「干净输入上的精度」换「脏输入上的鲁棒性」。
至于这笔交易划不划算,取决于你实际拿到的数据有多干净。

三、训练时该弄多脏

噪声强度是个超参数,选不好会白练。 下面固定测试输入为「坏 20%」,只改训练时的噪声强度:

训练噪声 干净输入下误差 坏 20% 输入下误差
0.00 0.0180 0.0984
0.05 0.0212 0.0794
0.10 0.0253 0.0721
0.20 0.0389 0.0698
0.30 0.0604 0.0787
0.45 0.0966 0.1308
这条曲线有最优解,而且不在两端。
噪声太小(0 到 0.05)学不到纠错;
噪声太大(0.45)连原图都看不清,网络只好学一个「平均脸」,什么细节都不敢留。
最好的一档在 0.10 到 0.20 之间,和你打算对付的损坏程度大致相当。
这条经验很实用:训练时的噪声强度,应该贴近部署时会遇到的噪声强度。
训练时温和、部署时恶劣,模型会措手不及。

四、睁眼看修出来的图

数字比图更能说明问题。下面把一张坏掉的图送进两个模型,各修一次。 修出来的图是连续值(0 到 1 之间的灰度),按 0.5 二值化之后再比。

这一行 与原图对上的像素
坏 20% 的输入 78.12%(50 / 64)
干净训练的模型修的 79.69%(51 / 64)
降噪训练的模型修的 85.94%(55 / 64)
干净模型只修回来 1 个像素,降噪模型修回来 5 个。
差 6 个像素听上去不多,但 8 乘 8 一共才 64 个像素,这就是十分之一。
更值得看的是形态:干净模型修出来的是一团模糊的笔画,位置大致对,但该亮的地方不够亮、
该暗的地方不够暗,二值化之后就成了断线。
降噪模型修出来的笔画是连着的——它见过这种损坏,知道哪里该补。

五、动手 Demo:两个模型对照:干净训练与降噪训练,谁抗噪

数据 450 张 8 乘 8 数字图,切 360 张训练、90 张测试。 两个模型的结构、参数量、轮数、学习率完全相同,唯一区别是训练输入脏不脏。 最后还会睁眼看看修出来的图长什么样——数字之外,图形本身也是证据。

def corrupt(X, rate, seed=0):
    u"""按概率逐像素翻转,rate 是「坏掉的比例」。"""
    rg = np.random.default_rng(seed)
    mask = rg.random(X.shape) < rate
    Y = X.copy()
    Y[mask] = 1.0 - Y[mask]
    return Y


# 普通自编码器:输入与目标是同一批干净数据
p_clean = train_ae(Xtr, Xtr)

# 降噪自编码器:输入是脏的,目标还是干净的
p_noise = train_ae(corrupt(Xtr, 0.15, seed=1), Xtr)

# 拿脏图试:谁还原得干净
for rate in (0.0, 0.10, 0.20, 0.30):
    Xc = corrupt(Xte, rate, seed=7)
    print(rate, mse(rec(Xc, p_clean), Xte), mse(rec(Xc, p_noise), Xte))

实跑输出:

--------------------------------------------------------------------
降噪自编码器:故意弄脏输入,逼它学结构
--------------------------------------------------------------------
[1] 降噪自编码器只改一处:输入弄脏,目标仍然是干净的
  普通自编码器的训练对是「输入 X,目标 X」,所以它只要学会复制就够了
  降噪自编码器训练对是「输入 X 加噪声,目标 X」,逼它学会「把脏东西还原」
  整个改动只有一行代码:喂进去之前先把输入按概率打翻几个像素

  两个模型:一个在干净输入上训练,一个在「坏掉 15% 像素」的输入上训练
  隐层都是 16 个,参数都是 2128 个,训练轮数与学习率完全相同

[2] 拿脏图喂进去,谁还原得干净
  测试输入            干净训练的重构误差        降噪训练的重构误差
  干净输入            0.0180                    0.0312
  坏 5%               0.0331                    0.0368
  坏 10%              0.0527                    0.0438
  坏 20%              0.0984                    0.0687
  坏 30%              0.1583                    0.1150

  干净输入上,两者几乎持平(干净训练那个还略好一点)——这很正常,
  因为它的训练分布就是干净的,在它擅长的场地里不该输。
  输入一脏,差距立刻拉开:坏 30% 时干净训练 0.1583 对降噪训练 0.1150,差三成。

[3] 训练时该弄多脏:噪声强度扫描
  训练噪声      干净输入下误差    坏 20% 输入下误差
  0.00          0.0180            0.0984
  0.05          0.0212            0.0794
  0.10          0.0253            0.0721
  0.20          0.0389            0.0698
  0.30          0.0604            0.0787
  0.45          0.0966            0.1308

  这条曲线有最优解,不在两端。
  噪声太小(0 到 0.05)学不到纠错,噪声太大(0.45)连原图都看不清了,只好学个平均值。
  最好的一档在 0.10 到 0.20 之间——和你打算对付的损坏程度大致相当。

[4] 睁眼看:脏图、两个模型修出来的图
  原图      坏 20%    干净训练修的  降噪训练修的
    ........  ........  ........  ........
    ........  #..##...  .####...  ...####.
    ......#.  ......#.  ....#.#.  ......#.
    ......#.  .....##.  ....#.#.  ......#.
    ......#.  ..###...  ..###.#.  ..#####.
    ......#.  ...#..##  ......#.  ......#.
    ......#.  ...#..#.  ...##.#.  ......#.
    ......#.  .###..#.  ..##..#.  ...#..#.

  坏 20% 的输入   与原图对上 78.12% 的像素(50 / 64)
  干净训练修的    与原图对上 79.69% 的像素(51 / 64)
  降噪训练修的    与原图对上 85.94% 的像素(55 / 64)

  干净模型修出来的是「一团模糊的笔画」:位置大致对,但很多该亮的地方不够亮、
  该暗的地方不够暗,二值化之后就成了断线。
  降噪模型修出来的更接近原来的笔画——它训练时见惯这种损坏,知道哪里该补。
  两条路线差 6 个像素(79.69% 对 85.94%),听上去不多,但 8 乘 8 一共才 64 个像素。

[5] 为什么加噪声反而学得更好
  普通自编码器有一条偷懒的路:把输入原样搬到输出,中间那层什么都不用学
  加噪声把这条路堵住了——输入已经不是目标,搬过去就是错的
  于是网络必须做一件事:从坏掉的数据里推断「本来应该是什么样」
  这个推断只能靠学到数据的规律:哪些像素该一起亮、哪些笔画不可能单独出现
  换句话说,噪声逼它去学「数据的形状」,而不是「数据的副本」

[6] 一句话记住它
  普通自编码器学的是「压缩」,降噪自编码器学的是「修复」,而修复必须先理解结构
  下一节换一种逼法:不弄脏输入,而是限制隐层「同时只能有几个单元亮」,
  那叫稀疏自编码器,它逼出来的是另一种结构——局部、专一的特征

第二段那张表是这一节的核心。 它同时否掉了两种极端说法:说「降噪模型全面更好」是错的(干净输入上它更差), 说「加噪声没用」也是错的(脏输入上差三成)。 正确的说法是:这是一笔交易,换的是「在哪种输入上更可靠」。 第三段的噪声扫描说明:噪声强度不是越大越好。 0.45 那一行两项都最差——训练时把图毁得太厉害,网络只好学一个模糊的平均值。 这也解释了为什么实际做降噪时,要按「真实噪声水平」来定这个值。 第四段那三行百分比要注意口径:比的是「二值化之后与干净原图一致的像素数」。 如果按连续值比,干净模型只差 1 个像素看起来更好,但它的图是糊的——灰度值接近 0.5, 二值化一碰就翻。 所以「看上去差不多」和「二值化后差很多」是两件事,评估去噪时常常要两样都看。

30 秒实验

  1. 把训练噪声 0.15 改成 0.30,再看「坏 20%」那一行的误差。 会从 0.0687 涨到 0.0787 附近——训练时弄得太脏反而学不好。
  2. 把 corrupt 从「逐像素翻转」改成「整块遮挡」(把某几行清成 0)。 这是另一种常见损坏形式,模型同样能学会补,但需要重新训练。
  3. 把隐层从 16 改成 4,再看降噪效果。 瓶颈太窄时,连干净图都还原不好,降噪自然一起垮——两个目标是耦合的。

常见误区

  1. 「加噪声是一种数据增强,等于变多了训练样本。」 不完全是。数据增强是把同一张图变换出多个版本,标签不变; 降噪自编码器是「输入变了、目标不变」,它训练的是「映射」而不是「扩充」。 两者都用到「变换」,但目的不同:一个为了泛化,一个为了学会修复。
  2. 「噪声越大,模型越鲁棒。」 实测 0.45 时两项误差都最差。 噪声大到一定程度,输入里的信息已经被毁掉,网络无法从中学到「什么是正常的」。 鲁棒性来自「见过适度损坏」,不是「见过彻底毁坏」。
  3. 「降噪自编码器就是去噪滤波器。」 它的副产品确实是去噪,但它真正学到的是「数据本来的结构」。 去噪只是这个结构能力的一个副产品;同样的编码拿去做分类、做检索,往往也更好用。
自测三问(点开对答案) 1. **降噪自编码器的训练对是什么?** —— **输入是「打翻若干像素的脏图」,目标是「对应的干净图」。** **两个不是同一张——这一点和前两种自编码器都不同。** **也是靠这个差别,它才被逼着学「修复」而不是「复制」。** 2. **为什么它在干净输入上反而更差?** —— **因为它训练时见到的都是脏输入,学到的映射是「从脏到干净」。** **干净输入不在它的训练分布里,处理起来当然不如专门练过干净数据的模型。** **这是所有「为某种分布优化」的模型的通病:换分布就掉。** 3. **噪声强度该怎么定?** —— **按部署时会遇到的损坏程度定,实测最佳在 0.10 到 0.20 之间。** **太小(0 到 0.05)学不到纠错,太大(0.45)连原图都看不清。** **如果实际损坏程度未知,就取覆盖范围最广的中间值,别取两端。**

降噪是往「输入」上加干扰,逼它学结构。 还有一种逼法:不动输入,而是在损失里加一项,限制隐层「同时只能亮几个单元」。 那叫稀疏自编码器,它逼出来的是另一种东西——隐层内部的分工。

相关笔记