📗 第3章 卷积神经网络
📄 一句话概括

卷积就是一个 3 乘 3 的小窗口在图上滑动,每一步把 9 个像素和 9 个权重对应相乘再相加。 换一组权重,输出就从「竖线图」变成「横线图」再变成「模糊图」。 尺寸公式只有一行:(输入 减 核 加 2 倍填充) 除以步长 再加 1。 8 乘 8 的图配 3 乘 3 的核,不填充输出 6 乘 6,填充 1 输出还是 8 乘 8。 权值共享把 2304 个权重压成 9 个,代价是「同一个核在所有位置用同一套判断」。

📝 本节和前面几节的关系

上一节把卷积网络当一个整体看,这一节只看一层。 卷积这一层的参数只有「核」和「偏置」,没有别的。 一个 3 乘 3 的核就是 9 个数,加上 1 个偏置,一共 10 个。 它的运算是:把核盖在图的某个位置上,对应位置相乘,全部加起来。 然后窗口挪一格,再算一次。 这件事和信号处理里的卷积几乎一样,只是这里不翻转核。

一、一次卷积的手算

取图左上角 3 乘 3 的一块,和核逐位相乘再求和:

图的一块        核              乘积
0 0 1          -1 0 1         0  0  1
0 0 1    乘     -1 0 1    =    0  0  1
0 0 1          -1 0 1         0  0  1

加起来 = 3

这一块左边是 0、右边是 1,所以「右边比左边亮 3」——这个核在找「从左往右变亮」的地方, 也就是竖着的边缘。 核的作用完全由那 9 个数决定。 把它转 90 度,它就变成找横边缘;全取 1 再除以 9,它就变成模糊。

二、三个手工设计的核

同一张 8 乘 8 的数字 2,三个不同的核:

核 9 个数 输出最大值 它找什么
竖线检测 左右两列正负相反 2.00 竖向的边缘
横线检测 上下两行正负相反 3.00 横向的边缘
模糊 九个数都是 1/9 0.56 邻域平均,去掉噪声
注意输出的范围完全不同:竖线核的输出在负 3 到 2 之间,模糊核在 0.11 到 0.56 之间。
这不是巧合:模糊核的九个权重加起来是 1,所以输出是「加权平均」,值一定落在输入的范围内;
边缘核的权重加起来是 0,输出可以是正也可以是负。
权重和等于 1 的核保持亮度,等于 0 的核提取变化——这是设计滤波器时的基本常识。
卷积网络里的核是训练出来的,但训练出来的核往往也有这个性质。

三、尺寸公式

输出边长 = (输入 减 核 加 2 倍填充) 除以步长 再加 1,向下取整。

输入 核 填充 步长 输出
8 3 0 1 6
8 3 1 1 8
8 5 0 1 4
8 3 0 2 3
8 3 1 2 4
三种常见配置:
不填充叫 valid,输出一定变小,这是最朴素的做法。
填充使输出与输入同尺寸叫 same,深层网络里几乎都用它,方便一层层叠。
步长大于 1 相当于顺便降采样,可以替代一部分池化。
填充填什么值?通常填 0。
填 0 的问题是边缘会被「压暗」,所以有时用边缘复制或镜像复制。
这些细节对最后几个百分点的准确率有影响,但不是结构性的。

四、权值共享到底省了多少

一张 8 乘 8 的图接一个 3 乘 3 的核: 核只有 9 个权重,但在 36 个位置上各用了一次,一共做了 324 次乘法。 换成全连接,从 64 个数映射到 36 个数,需要 64 乘 36 等于 2304 个权重。 共享把 2304 压成了 9,压了 256 倍。 代价是「同一个核在所有位置做同一套判断」。 这个代价恰好是想要的。 一条竖线在左上角还是右下角,都是竖线;「有没有竖线」这个判断不该随位置改变。 把这个先验写进结构里,就不用靠数据去学了。 这也是「归纳偏置」这个词的意思:结构里预先假设了什么。 卷积假设「局部模式在任何位置都一样」,池化假设「小幅移动不改变类别」。 假设对了就省数据,假设错了就是束缚。

五、实现上的关键一步

直接写双重循环滑窗,在 Python 里慢得没法用。 实际做法是 im2col:先把每个窗口摊成一行,得到一个「窗口数 乘 9」的矩阵, 再和「9 乘 通道数」的核矩阵做一次乘法。

原来的形状    (n, 6, 6, 1)
拆窗之后      (n, 36, 9)      每个窗口一行
核矩阵        (9, 4)         4 个核
一次矩阵乘    (n, 36, 4)      就是卷积的结果

这样卷积就变成了矩阵乘法,可以直接吃 BLAS 和 GPU。 代价是内存:拆窗会把同一批像素复制 9 遍。 所以真实框架里 im2col 常常和分块一起用,控制显存。

六、动手 Demo:一个滑动的小窗口,三个手工设计的核

这段代码手写了一个单通道卷积,把数字 2 分别过三个手工设计的核:竖线检测、横线检测、模糊; 再把尺寸公式的五种配置列成表;最后算权值共享省了多少。 重点看三处。第二段三张输出图完全不同;第三段的 6、8、4、3、4;第四段 2304 对 9。

# -*- coding: utf-8 -*-
import unicodedata

import numpy as np


def wd(s):
    return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)


def pad(s, n):
    return s + ' ' * max(0, n - wd(s))


def one_digit(k, size=8):
    SEG = {2: 'abged'}
    POS = {'a': (1, 2, 5), 'b': (2, 5, 3), 'c': (5, 5, 3), 'd': (7, 2, 4),
           'e': (5, 1, 3), 'f': (2, 1, 3), 'g': (4, 2, 4)}
    img = np.zeros((size, size))
    for sg in SEG[k]:
        r, c0, ln = POS[sg]
        if sg in ('b', 'c'):
            img[r:r + ln, c0] = 1.0
        else:
            img[r, c0:c0 + ln] = 1.0
    return img


def conv1(img, k):
    """单通道卷积(valid,步长 1):输出尺寸 (H-k+1, W-k+1)。"""
    H, W = img.shape
    oh, ow = H - k.shape[0] + 1, W - k.shape[1] + 1
    out = np.zeros((oh, ow))
    for i in range(oh):
        for j in range(ow):
            out[i, j] = float(np.sum(img[i:i + k.shape[0], j:j + k.shape[1]] * k))
    return out


def show(mat, fmt='%+.1f'):
    for row in mat:
        print('    ' + ' '.join(fmt % v for v in row))


img = one_digit(2)

print('[1] 卷积就是一个滑动的小窗口')
print('  取一个 3 乘 3 的核,压在图上左上角,把 9 个像素和 9 个权重对应相乘再相加。')
print('  得到一个数,然后窗口往右挪一格,再算一次。挪到头就换下一行。')
print('')
print('  这张 8 乘 8 的图(数字 2):')
show(img, '%.0f')
print('')

print('[2] 三个手工设计的核,输出完全是三张不同的图')
K = {}
K['竖线检测'] = np.array([[-1., 0., 1.], [-1., 0., 1.], [-1., 0., 1.]])
K['横线检测'] = np.array([[-1., -1., -1.], [0., 0., 0.], [1., 1., 1.]])
K['模糊'] = np.ones((3, 3)) / 9.0
for name, k in K.items():
    out = conv1(img, k)
    print('  ' + name + '的核:')
    show(k, '%.2f')
    print('    输出:')
    show(out)
    print('    输出形状 %s,最大 %.2f,最小 %.2f'
          % (str(out.shape), out.max(), out.min()))
    print('')

print('[3] 尺寸怎么算')
print('  公式:(输入边长 减 核边长 加 2 倍填充) 除以步长 再加 1')
print('')
print('  输入   核   填充   步长   输出')
for H, k, p, s in ((8, 3, 0, 1), (8, 3, 1, 1), (8, 5, 0, 1), (8, 3, 0, 2), (8, 3, 1, 2)):
    o = (H - k + 2 * p) // s + 1
    print('  ' + pad('%d' % H, 7) + pad('%d' % k, 6) + pad('%d' % p, 7)
          + pad('%d' % s, 7) + '%d' % o)
print('')
print('  填充 1 时输出和输入一样大,这叫 same 卷积;不填充叫 valid,输出会缩。')
print('  步长 2 时边长差不多减半,所以它也是一种降采样。')
print('')

print('[4] 权值共享省了多少')
print('  一张 8 乘 8 的图接一个 3 乘 3 的核:核只有 9 个权重(加 1 个偏置)。')
print('  但这 9 个数在 36 个位置上各用了一次,一共做了 %d 次乘法。' % (36 * 9))
print('  换成全连接的话,8 乘 8 到 6 乘 6 需要 64 乘 36 等于 %d 个权重。' % (64 * 36))
print('  共享把 2304 个权重压成了 9 个,代价是「同一个核在所有位置用同一套判断」。')
print('  这个代价恰好是想要的:一条竖线在左上角还是右下角,都是竖线。')

实跑输出:

--------------------------------------------------------------------
卷积层:一个滑动的小窗口,三个手工设计的核
--------------------------------------------------------------------
[1] 卷积就是一个滑动的小窗口
  取一个 3 乘 3 的核,压在图上左上角,把 9 个像素和 9 个权重对应相乘再相加。
  得到一个数,然后窗口往右挪一格,再算一次。挪到头就换下一行。

  这张 8 乘 8 的图(数字 2):
    0 0 0 0 0 0 0 0
    0 0 1 1 1 1 1 0
    0 0 0 0 0 1 0 0
    0 0 0 0 0 1 0 0
    0 0 1 1 1 1 0 0
    0 1 1 1 0 0 0 0
    0 0 0 0 0 0 0 0
    0 0 1 1 1 1 0 0

[2] 三个手工设计的核,输出完全是三张不同的图
  竖线检测的核:
    -1.00 0.00 1.00
    -1.00 0.00 1.00
    -1.00 0.00 1.00
    输出:
    +1.0 +1.0 +0.0 +1.0 +0.0 -2.0
    +1.0 +1.0 +0.0 +2.0 +0.0 -3.0
    +1.0 +1.0 +0.0 +2.0 -1.0 -3.0
    +2.0 +1.0 -1.0 +0.0 -1.0 -2.0
    +2.0 +1.0 -1.0 -1.0 -1.0 -1.0
    +2.0 +1.0 -1.0 -1.0 -1.0 -1.0
    输出形状 (6, 6),最大 2.00,最小 -3.00

  横线检测的核:
    -1.00 -1.00 -1.00
    0.00 0.00 0.00
    1.00 1.00 1.00
    输出:
    +0.0 +0.0 +0.0 +1.0 +1.0 +1.0
    -1.0 -2.0 -3.0 -2.0 -2.0 -1.0
    +1.0 +2.0 +3.0 +2.0 +1.0 +0.0
    +2.0 +3.0 +2.0 +0.0 -1.0 -1.0
    -1.0 -2.0 -3.0 -3.0 -2.0 -1.0
    -1.0 -1.0 +1.0 +2.0 +2.0 +1.0
    输出形状 (6, 6),最大 3.00,最小 -3.00

  模糊的核:
    0.11 0.11 0.11
    0.11 0.11 0.11
    0.11 0.11 0.11
    输出:
    +0.1 +0.2 +0.3 +0.4 +0.4 +0.3
    +0.1 +0.2 +0.3 +0.6 +0.6 +0.4
    +0.1 +0.2 +0.3 +0.6 +0.4 +0.3
    +0.3 +0.6 +0.6 +0.6 +0.3 +0.2
    +0.3 +0.6 +0.6 +0.4 +0.2 +0.1
    +0.3 +0.6 +0.6 +0.4 +0.2 +0.1
    输出形状 (6, 6),最大 0.56,最小 0.11

[3] 尺寸怎么算
  公式:(输入边长 减 核边长 加 2 倍填充) 除以步长 再加 1

  输入   核   填充   步长   输出
  8      3     0      1      6
  8      3     1      1      8
  8      5     0      1      4
  8      3     0      2      3
  8      3     1      2      4

  填充 1 时输出和输入一样大,这叫 same 卷积;不填充叫 valid,输出会缩。
  步长 2 时边长差不多减半,所以它也是一种降采样。

[4] 权值共享省了多少
  一张 8 乘 8 的图接一个 3 乘 3 的核:核只有 9 个权重(加 1 个偏置)。
  但这 9 个数在 36 个位置上各用了一次,一共做了 324 次乘法。
  换成全连接的话,8 乘 8 到 6 乘 6 需要 64 乘 36 等于 2304 个权重。
  共享把 2304 个权重压成了 9 个,代价是「同一个核在所有位置用同一套判断」。
  这个代价恰好是想要的:一条竖线在左上角还是右下角,都是竖线。

第二段三张输出图要竖着看,不要只看数值。 竖线核的输出在「笔画的两侧」出现正负交替,这正是边缘的样子。 横线核的输出则在上下两侧出现正负——同一个形状,换个方向就换个核。 模糊核的输出全是正数、范围很窄(0.11 到 0.56)。 因为它的权重和是 1,输出是九邻域的平均值,不可能超出输入范围。 这一点可以用来检查「这个核是不是在做平均」。 第三段那张表要记住两个特殊值:填充 1 时输出等于输入(same),步长 2 时输出差不多减半。 这两条在搭网络时天天用。 第四段那个 256 倍不是巧合, 它等于「窗口数」——因为共享就是把「每个窗口一套权重」变成「所有窗口一套权重」。 所以图越大,共享省得越多。 代码里的 conv1 是双重循环,只在 8 乘 8 上能这么写。 第 3.1 节那个 im2col 才是能上生产环境的写法,两者算出来是同一个结果。

30 秒实验

  1. 把 K['竖线检测'] 的符号整体反过来,重跑。输出会变成原来的相反数。 因为卷积是线性运算,核乘负一,输出就乘负一。
  2. 把模糊核改成九个数都是 1(不除以 9),重跑。输出会放大 9 倍,最大值到 5 左右。 权重和不再等于 1,它就不再是「平均」了。
  3. 把 conv1 里的 k.shape[0] 改用 5 乘 5 的核(自己造一个),重跑。输出会变成 4 乘 4。 手工验证一下公式 (8 减 5) 除以 1 加 1 等于 4。

常见误区

  1. 「卷积就是矩阵乘法,所以没什么特别」。 它是「带约束的矩阵乘法」:大部分权重被强制为 0(只连邻域), 剩下的权重被强制相等(共享)。 约束就是先验,先验就是省参数。
  2. 「核越大越好,看到的东西越多」。 核大则窗口少、参数多、计算贵。 今天更常见的做法是堆两层 3 乘 3,等效视野是 5 乘 5,但参数只有 18 个而不是 25 个。
  3. 「卷积要做翻转,因为数学定义里有翻转」。 深度学习里的「卷积」其实是互相关,不翻转。 因为核是学出来的,翻不翻转只是换个记法,不影响能力。
  4. 「填充会引入假信息,所以不该用」。 不填充的话每过一层尺寸就缩,几层之后图就没了。 填 0 确实会在边缘制造人为的「暗边」,但对多数任务影响有限。
自测三问(点开对答案) 1. 卷积的尺寸公式是什么?举两个例子。 —— 输出边长等于 (输入 减 核 加 2 倍填充) 除以步长 再加 1,向下取整。 8 乘 8 配 3 乘 3、不填充、步长 1,输出 6 乘 6;填充 1 时输出还是 8 乘 8(same 卷积)。 2. 为什么权重和等于 1 的核输出范围很窄,等于 0 的核可以是负数? —— 权重和等于 1 时,输出是输入的加权平均,必然落在输入的最小值和最大值之间。 权重和为 0 时,输出是「变化量」,可以为正也可以为负。 所以模糊核输出 0.11 到 0.56,边缘核输出负 3 到 2。 3. 权值共享省了多少参数?它的代价是什么? —— 8 乘 8 到 6 乘 6,全连接要 2304 个权重,共享后只要 9 个,差 256 倍(等于窗口数)。 代价是「同一个核在所有位置做同一套判断」——这在图像上是合理的先验, 在输入没有邻接结构时就成了束缚。

卷积之后尺寸变小、通道变多,但 6 乘 6 还是太大。 下一步是把每个小块的响应压成一个数——这就是池化层。 它不学参数,却换来了「挪一点也认得出」。

相关笔记