上一节的网络有一个隐含着的大问题:它对「图长什么样」一无所知。 它把一张 8 乘 8 的图摊成 64 个数,然后当成 64 个互不相关的量来处理。 卷积神经网络的全部工作,就是把「图是有空间结构的」这件事写进网络里。 手段只有三个:小模板、权重共享、池化。这一节把三个都量一遍。
这一节是 9.1 那个「隐单元在切直线」的直接延伸。那里的隐单元看的是「整张图」, 这里的小模板只看 9 个像素——因为图像里的规律本来就是局部的。 它跟 9.2 是一对:RBM 是「无监督学一堆小块」,CNN 是「有监督学一堆小块」。 两者都在赌同一件事:图的规律是局部的、可复用的。
一、卷积:拿一个小模板在图上滑
先把手写「0」摊在眼前,然后用三个 3 乘 3 的小模板各滑一遍。模板本身只是一堆数: 横线模板是「上排全 1、下排全 −1」,它跟图像越像的地方,响应越大。
def conv(img, k): # 拿一个小模板,在图上滑一遍
out = np.zeros((img.shape[0] - k.shape[0] + 1,
img.shape[1] - k.shape[1] + 1))
for i in range(out.shape[0]):
for j in range(out.shape[1]):
out[i, j] = (img[i:i + 3, j:j + 3] * k).sum()
return out
def pool(img, n=2): # 每 n 乘 n 一小块,只留最大的那个
return np.array([[img[i * n:(i + 1) * n, j * n:(j + 1) * n].max()
for j in range(img.shape[1] // n)]
for i in range(img.shape[0] // n)])
# 判断两种表示「像不像」:直接用相关系数,1 是完全一样
def corr(a, b):
a = a.ravel() - a.mean()
b = b.ravel() - b.mean()
return (a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum())
滑一遍的结果是一张 6 乘 6 的响应图,不是 8 乘 8。 因为 3 乘 3 的模板在 8 乘 8 上只能放 6 乘 6 个位置—— 这就是「有效卷积」的尺寸算法:8 − 3 + 1 = 6。 看那张响应图会发现:横线模板在「0」的最上面和最下面最亮,竖线模板在左右两侧最亮。 这不是巧合,那就是这个数字的形状。 所以卷积这一步是在干什么?它把「这张图里, 某个位置有没有某种小形状」变成了一个数。这一步不涉及任何学习——模板是你给的。
二、权重共享:参数从一千掉到四十
现在算一笔账。同样一张 8 乘 8 的图,接一个 16 个单元的全连接隐层,对上一个 3 乘 3、 4 个核的卷积层:
| 做法 | 输出多大 | 参数量 | 怎么算的 |
|---|---|---|---|
| 全连接:64 全连到 16 | 16 个数 | 1040 | 64 乘 16 加 16 |
| 卷积:3 乘 3 乘 4 个核 | 6 乘 6 乘 4 | 40 | 9 乘 4 加 4 |
| 参数量差了 26 倍,而卷积给出的数是 144 个——比全连接还多。 | |||
| 省下来的参数来自一条规矩:同一块模板,在整张图上用同一套权重。 | |||
| 这就是「权重共享」。 | |||
| 它同时干了两件事,而这两件事其实是一件事的两面: | |||
| 一面是参数变少——9 个数要在 36 个位置上用,自然就省; | |||
| 另一面是「不管出现在哪儿都认」——同一个模板在左上角和右下角是同一套权重, | |||
| 所以它的响应跟位置无关。 | |||
| 反过来看全连接:它有 1040 个参数,意味着它可以给「第 25 个像素」一个专门的权重。 | |||
| 这份自由是它的表达力,也是它的死穴——一旦图挪了位置,那个权重就全错了。 |
三、平移:像素会懵,卷积加池化不慌
刚才那句「死穴」是真的,得量。把「0」放进一张 12 乘 12 的空画布中央, 整体挪一格、两格,再看三种表示跟原图有多像(1 是完全一样,0 是毫无关系):
| 挪动 | 原始像素 | 卷积响应 | 卷积加池化 |
|---|---|---|---|
| 右移 1 格 | +0.187 | +0.418 | +0.686 |
| 右移 2 格 | +0.071 | −0.050 | +0.200 |
| 下移 1 格 | +0.187 | +0.418 | +0.686 |
| 右下各两格 | −0.045 | −0.120 | −0.034 |
| 原始像素最惨:挪一格就掉到 0.187,挪两格基本没关系了。 | |||
| 因为「某几个像素亮」这件事,在图挪一格之后就不成立了。 | |||
| 卷积响应好一些(0.418),但也好不到哪里去——它的响应图自己也跟着挪了。 | |||
| 换句话说:卷积解决了「同一套权重」,但没解决「位置要报出来」。 | |||
| 只有再加一层池化,相关系数才明显站住(0.686)。 | |||
| 所以有一句反直觉但很重要的话:平移不变性不是卷积给的,是池化给的。 |
四、池化不是白拿的
池化做的事很简单:把响应图切成小块,每块只留最大的那个。 于是「在这儿」和「往右挪了半格」在池化之后报同一个数——这就是不变性的来源。 但代价也很直接。把窗口从 1(等于不池化)加到 2、3:
| 池化窗口 | 右移 1 格 | 右移 2 格 | 还剩几个数 |
|---|---|---|---|
| 1 乘 1 | +0.418 | −0.050 | 300 |
| 2 乘 2 | +0.686 | +0.200 | 75 |
| 3 乘 3 | +0.549 | +0.271 | 27 |
| 窗口加到 2,「挪一格」的相关系数从 0.418 跳到 0.686。 | |||
| 可再加到 3 反而回落了(0.549),只有「挪两格」还在慢慢变好。 | |||
| 而剩下的数从 300 一路掉到 27。 | |||
| 这就是池化的取舍:越不怕挪,越分不清「差一格」和「差两格」。 | |||
| 对认数字来说这个取舍划算——同一个数字本来就写得位置不固定。 | |||
| 可要是任务本身要求精确定位(比如找病灶在哪个像素),池化就是有害的。 | |||
| 所以「池化层放几层、窗口多大」不是一个默认值,是要按任务调的。 | |||
| 这条规矩在 10.3 的图像识别里会被反复用到。 |
五、真跑一个:随机卷积核也有用
最后做一个能一锤定音的实验。三张手写数字(0/1/2), 训练集全部居中,测试集里有一半是「随机平移过 ±2 格」的。 模型从来没见过歪着放的数字。
| 用哪些特征 | 维数 | 居中测试 | 平移后测试 |
|---|---|---|---|
| 原始 144 个像素 | 144 | 1.0000 | 0.3500 |
| 8 个随机核,不池化 | 800 | 1.0000 | 0.3150 |
| 8 个随机核,加池化 | 200 | 1.0000 | 0.5050 |
| 居中的时候三者都是 1.0000——这个任务本身不难,别被满分骗了。 | |||
| 一平移就分家了:像素掉到 0.3500,光有卷积反而更差(0.3150)。 | |||
| 只有「卷积加池化」明显好(0.5050)。 | |||
| 这个结果跟第 3 段那张表完全对得上:卷积单独不含不变性,池化才补上那一块。 | |||
| 两段用完全不同的方法(相关系数、分类准确率)得出了同一个结论,这才可信。 | |||
| 还有一件事必须指出:这些卷积核是随机生成的,一个都没训练过。 | |||
| 所以这份好处来自「结构」,不来自「学习」。 | |||
| 真正的 CNN 会把核也一起训出来,那是 10.3 图像识别要讲的。 |
六、动手 Demo:拿一个小模板,在图上滑一遍
五段:卷积核、参数账、平移、池化窗口、随机卷积特征的分类。 第 3 段和第 5 段是用两种完全不同的方法验证同一件事—— 这种「两条路走到同一个结论」比单次实验结果可靠得多。 这一节的卷积、池化都是几十行手写循环,跑起来不慢(3 秒左右)。 因为图只有 8 乘 8,慢不到哪儿去——真实图像是几百万像素,那就必须用专门的库了。
def conv(img, k): # 拿一个小模板,在图上滑一遍
out = np.zeros((img.shape[0] - k.shape[0] + 1,
img.shape[1] - k.shape[1] + 1))
for i in range(out.shape[0]):
for j in range(out.shape[1]):
out[i, j] = (img[i:i + 3, j:j + 3] * k).sum()
return out
def pool(img, n=2): # 每 n 乘 n 一小块,只留最大的那个
return np.array([[img[i * n:(i + 1) * n, j * n:(j + 1) * n].max()
for j in range(img.shape[1] // n)]
for i in range(img.shape[0] // n)])
# 判断两种表示「像不像」:直接用相关系数,1 是完全一样
def corr(a, b):
a = a.ravel() - a.mean()
b = b.ravel() - b.mean()
return (a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum())
实跑输出:
--------------------------------------------------------------------
拿一个小模板,在图上滑一遍
--------------------------------------------------------------------
==============================================================
[1] 卷积就是「拿一个小模板在图上滑」
取一张 8 乘 8 的手写「0」,先看看三个 3 乘 3 的小模板各自认什么:
..####..
.#....#.
#......#
#......#
#......#
#......#
.#....#.
..####..
用「横线」模板滑出来的 6 乘 6 响应图(越深响应越大):
:####:
:+::+:
::::::
::::::
:.::.:
: :
最强的地方在第 1 行第 3 列(原图那个位置正好是横线)
用「竖线」模板滑出来的 6 乘 6 响应图(越深响应越大):
::::::
#+::.
#::::
#::::
#+::.
::::::
最强的地方在第 3 行第 1 列(原图那个位置正好是竖线)
用「斜线」模板滑出来的 6 乘 6 响应图(越深响应越大):
..:#
:.. :
......
......
: ..:
#:..
最强的地方在第 1 行第 6 列(原图那个位置正好是斜线)
**一个 3 乘 3 的模板,就是一个「找某种局部形状」的探测器。**
它只认 9 个像素、只看一小块,**那儿的小图案跟它越像,响应就越大。**
**这就是卷积的全部:模板不变、位置在变。**
[2] 参数账:同一张图,全连接和卷积差多少
8 乘 8 的图,接一个 16 个单元的全连接隐层,对上一个 3 乘 3、4 个核的卷积层:
做法 输出多大 参数量 怎么算的
--------------------------------------------------------------------------
全连接:64 全连到 16 16 个数 1040 64 × 16 + 16
卷积:3 乘 3 乘 4 个核 6 乘 6 乘 4 40 9 × 4 + 4
**参数量差了 26 倍,而卷积给出的数是 144 个——比全连接还多。**
**省下来的参数来自一条规矩:同一块模板,在整张图上用同一套权重。**
**这就是「权重共享」。它同时干了两件事:参数变少,而且「不管出现在哪儿都认」。**
[3] 平移:像素会懵,卷积加池化不慌
把「0」整体挪一挪,看三种表示跟原图有多像(1 是完全一样,0 是毫无关系):
**为了不挪出边界,先把它放进一张 12 乘 12 的空画布中央。**
挪动 原始像素 卷积响应 卷积加池化 谁最稳
--------------------------------------------------------------------------
右移 1 格 +0.187 +0.418 +0.686 池化
右移 2 格 +0.071 -0.050 +0.200 池化
下移 1 格 +0.187 +0.418 +0.686 池化
右下各两格 -0.045 -0.120 -0.034 池化
**一挪,原始像素的相关系数就掉到 0.1 上下**——因为「某几个像素亮」这件事
在图挪一格之后就不成立了。
**卷积响应好一些,但也好不到哪里去**——它的响应图自己也跟着挪了。
**只有再加一层池化,相关系数才明显站住:**同一块里取最大值,
**「在这儿」和「往右挪一格」在池化之后就报同一个数了。**
[4] 池化不是白拿的:窗口越大,细节越糊
把池化窗口从 1(等于不池化)加到 2、3,看「平移之后的相关系数」和「还剩几个数」:
池化窗口 右移 1 格 右移 2 格 还剩几个数 说明
--------------------------------------------------------------------------
1 乘 1 +0.418 -0.050 300 最细
2 乘 2 +0.686 +0.200 75 均衡
3 乘 3 +0.549 +0.271 27 最不怕挪
**窗口从 1 加到 2,「挪一格」的相关系数从 0.4 一下跳到 0.7;**
**可再加到 3 反而回落了,只有「挪两格」还在慢慢变好。**
**而剩下的数从 300 一路掉到 27。**
**这就是池化的取舍:越不怕挪,越分不清「差一格」和「差两格」。**
**对认数字来说这个取舍划算**(同一个数字本来就写得位置不固定);
**可要是任务本身要求精确定位,池化就是有害的。**
[5] 真跑一个:随机卷积特征 vs 原始像素
三张手写数字(0/1/2),放进 12 乘 12 的画布,随机翻 3% 的像素:
300 张训练(全部居中)、200 张居中测试、200 张随机平移 ±2 格的测试
**注意:模型从来没见过「歪着放的数字」。**
用哪些特征 维数 居中测试 平移后测试
--------------------------------------------------------------------
原始 144 个像素 144 1.0000 0.3500
8 个随机核,不池化 800 1.0000 0.3150
8 个随机核,加池化 200 1.0000 0.5050
**居中的时候三者都是 1.0000——这个任务本身不难。**
**一平移就分家了:像素掉到 0.35,光有卷积反而更差(0.32)。**
**只有「卷积加池化」明显好(0.51)。**
**这是个反直觉但很重要的结论:平移不变性不是卷积给的,是池化给的。**
卷积给的是「同一个模板在哪儿都用同一套权重」,**可它老老实实把位置报了出来**;
**池化把这个位置抹掉,才真的不变。**
**这些卷积核是随机生成的,一个都没训练过——所以这份好处来自「结构」,不来自「学习」。**
真正的 CNN 会把核也一起训出来,那是 10.3 图像识别要讲的。
五段连起来:**卷积是小模板在滑**;**权重共享把参数砍下来**;
**于是它到哪儿都认**;**池化再把「差半格」抹平**;
**这三件事合起来,就是 CNN 为什么能认图。**
第 1 段要盯着响应图看:横线模板亮在上下,竖线模板亮在左右。 那不是卷积算出来的规律,那是「0」这个字本身的形状。 卷积只是把形状变成了数。 第 2 段那个「26 倍」不是这一节的重点,重点是它为什么能省。 省掉的每一个参数,都对应一条「这个位置和那个位置用同一套权重」的假设—— 假设对了,参数就白赚;假设错了,模型就学不会。 第 3 段那个「卷积比像素还差一点」是最容易被忽略的一行。 它说明「用卷积」本身不带来不变性。只做卷积不做池化,等于换了个方式记位置。 第 4 段那个「窗口加到 3 反而回落」很有意思。 它说明池化不是「越大越不变」——窗口一大,相邻两块的信息被强行合并, 连「挪一格」和「挪三格」都分不清了,相关系数自然会重新掉下去。 第 5 段那个 0.5050 是这个 Demo 的落点。一半的准确率听起来不高, 可基线只有 0.35,而且模型完全没见过平移过的样本。 这就是「把结构写进网络」值多少钱。
30 秒实验
- 把第 2 段那个卷积核从 4 个改成 16 个,重跑参数账。 参数量从 40 涨到 148,仍然远小于全连接的 1040—— 权重共享省下来的钱跟核的个数无关,是结构性的。
- 把第 5 段的
smax从 2 改成 1(只平移一格),重跑。 三种特征的差距会缩小——平移越轻微,原始像素越不容易崩,卷积的优势也就越不明显。 - 把第 5 段那 8 个随机核换成本 Demo 里那三个手工核(横线、竖线、斜线),重跑。 「加池化」那一行可能还会更好一点——因为手工核更贴合这个任务。 这也是 CNN 要「学」核的理由:你未必知道该用哪种。
常见误区
- 以为卷积就等于「平移不变」。Demo 第 3 段:只做卷积,相关系数是 0.418, 比原始像素好一点但很有限;加上池化才到 0.686。 不变性是池化给的。
- 以为池化越大越好。Demo 第 4 段:窗口 2 乘 2 时「挪一格」是 0.686, 换成 3 乘 3 反而掉到 0.549,而且信息量从 75 个掉到 27 个。
- 以为「卷积一定比全连接强」。Demo 第 5 段: 不做池化的卷积(0.3150)比原始像素(0.3500)还差。
- 以为「随机卷积核也有用,所以核不用学」。随机核的价值在于证明了「结构本身有效」; 真要做事,还是得把核学出来——那正是 10.3 的内容。
自测三问(点开对答案)
1. 为什么卷积层的参数比全连接层少那么多,输出反而更多? —— 因为**权重共享**:9 个数要在 36 个位置上反复用,**所以参数只有 9 乘 4 加 4 = 40 个**; **而输出是 6 乘 6 乘 4 = 144 个数**。全连接要 1040 个参数才给出 16 个数。 **这等于用「同一套权重在哪儿都用」这条假设,换掉了大量自由参数。** 2. 「平移不变性来自池化,不来自卷积」,这句话怎么验证? —— **看两个数就够了。**Demo 第 3 段:右移一格时,原始像素的相关系数是 0.187, **只做卷积是 0.418,加上池化才是 0.686。** **卷积只是让权重共享了,但它的响应图自己也跟着挪,所以位置信息还在;** **池化把「同一块里最大的那个」留下,位置才真的被抹掉。** 3. 为什么池化窗口开得越大,效果不一定越好? —— 因为**池化是在用「位置精度」换「位置容忍度」**。 Demo 第 4 段:窗口从 2 乘 2 换到 3 乘 3, **「挪两格」的相关系数从 0.200 涨到 0.271,可「挪一格」反而从 0.686 掉到 0.549**—— **窗口太大,连「差一格」和「差三格」都分不清了。** **而剩下的特征数从 75 掉到 27。**第 9 章到这儿结束。四节连起来是一条完整的路: 9.1 说必须叠层,9.2 说怎么把第一层垫好,9.3 说怎么叠得更高, 9.4 说叠的时候顺便把图的结构用上。 下一章换一个角度:不讲「网络怎么搭」,讲「图像和语音到底怎么被认出来」。
- ☐ (本章完,下一节 10.1 模式识别)