📘 第9章 深度学习
📄 一句话概括

上一节的网络有一个隐含着的大问题:它对「图长什么样」一无所知。 它把一张 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 秒实验

  1. 把第 2 段那个卷积核从 4 个改成 16 个,重跑参数账。 参数量从 40 涨到 148,仍然远小于全连接的 1040—— 权重共享省下来的钱跟核的个数无关,是结构性的。
  2. 把第 5 段的 smax 从 2 改成 1(只平移一格),重跑。 三种特征的差距会缩小——平移越轻微,原始像素越不容易崩,卷积的优势也就越不明显。
  3. 把第 5 段那 8 个随机核换成本 Demo 里那三个手工核(横线、竖线、斜线),重跑。 「加池化」那一行可能还会更好一点——因为手工核更贴合这个任务。 这也是 CNN 要「学」核的理由:你未必知道该用哪种。

常见误区

  1. 以为卷积就等于「平移不变」。Demo 第 3 段:只做卷积,相关系数是 0.418, 比原始像素好一点但很有限;加上池化才到 0.686。 不变性是池化给的。
  2. 以为池化越大越好。Demo 第 4 段:窗口 2 乘 2 时「挪一格」是 0.686, 换成 3 乘 3 反而掉到 0.549,而且信息量从 75 个掉到 27 个。
  3. 以为「卷积一定比全连接强」。Demo 第 5 段: 不做池化的卷积(0.3150)比原始像素(0.3500)还差。
  4. 以为「随机卷积核也有用,所以核不用学」。随机核的价值在于证明了「结构本身有效」; 真要做事,还是得把核学出来——那正是 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 说叠的时候顺便把图的结构用上。 下一章换一个角度:不讲「网络怎么搭」,讲「图像和语音到底怎么被认出来」。

相关笔记