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

同样做 8 乘 8 数字图的十类分类,全连接要 2410 个参数,卷积只要 410 个。 少的不只是数量,是「重复」:同一个 3 乘 3 核在 36 个位置上用同一组 9 个权重。 一次前向的形状:8 乘 8 到 6 乘 6 乘 4 到 3 乘 3 乘 4 到 36 到 10。 每一步都在降尺寸、加通道,这是卷积网络的基本节奏。

📝 本节和前面几节的关系

第 2 章的网络有个明显的浪费:把图摊平成一个长向量。 64 个数排成一列之后,「谁和谁挨着」这件事就没了。 可图像的意思恰恰存在于邻接关系里:一条竖线是一列相邻的亮点,不是 8 个孤立的数。 卷积层就是把「邻接」这件事写进结构里。 它只连相邻的像素,而且同一套权重在所有位置重复使用。 这两条限制看起来是「能力变弱」,实际换来的是参数数量骤降、平移鲁棒。

一、为什么不能把图摊平

摊平之后,网络面对的是 64 个互不相干的输入。 它要靠训练自己发现「第 1 个和第 2 个数经常一起亮」这件事。 这能学会,但代价是每个位置都要单独学一遍。 更要紧的是,位置一变就全乱。 同一个「2」,画在左上角和画在正中间,摊平之后是完全不同的两个向量。 网络得为每个位置分别记住「2 长什么样」。 卷积的做法是:只学一次「2 的局部笔画长什么样」,然后拿到所有位置上去比对。 这就把「位置」和「形状」拆开了。

二、参数账单

两种结构做个十类分类:

结构 参数量
全连接 64 到 32 到 10 2410
卷积 4 核 3x3,池化 2x2,全连接 36 到 10 410
卷积版只有全连接版的 17.0%,参数少了 5.9 倍。
而这两者在这个任务上的准确率是同一个量级。
参数少的直接好处是过拟合风险低、显存占用小、推理快。
间接好处更重要:省下来的预算可以拿去堆层数。
AlexNet 能堆 8 层、ResNet 能堆 152 层,前提就是卷积把每层的参数量压住了。

三、一次前向的形状变化

| 步骤 | 形状 | 做了什么 | |---|---|---|---| | 输入 | 12 乘 8 乘 8 | 12 张 8 乘 8 的灰度图 | | 拆窗 | 12 乘 36 乘 9 | 每张图切出 36 个 3 乘 3 窗口 | | 卷积 | 12 乘 6 乘 6 乘 4 | 4 个核各出一张特征图 | | ReLU | 12 乘 6 乘 6 乘 4 | 负数清零 | | 最大池化 | 12 乘 3 乘 3 乘 4 | 每 2 乘 2 块留最大值 | | 摊平 | 12 乘 36 | 排成一列 | | 全连接 | 12 乘 10 | 输出 10 类的 logits | 这个节奏要记住:尺寸一路降(8 到 6 到 3),通道一路升(1 到 4 到 4 再到 10)。 尺寸降是为了减少后面的计算量,通道升是为了让不同的特征各占一个通道。 第 2 章的网络是「一直宽」,卷积网络是「先宽后窄」。 后者更符合图像的结构:越靠前的层看局部细节,越靠后的层看整体。

四、三个部件各管什么

卷积层:找局部模式。 一个 3 乘 3 的核就是一个小小的模板,它回答「这块区域像不像我要找的东西」。 池化层:降尺寸,顺带换来小范围平移不变。 它不学任何参数,只是每块取最大值或平均值。 全连接层:把特征图变成判断。 它不管空间结构,只做加权求和——所以它必须放在最后。 三者的顺序几乎是固定的:卷积、激活、池化,重复若干次,最后接全连接和输出层。 这个模板从 1989 年的 LeNet 一直用到今天的 ResNet,没有本质变化。

五、这一节的 Demo 在看什么

它只做前向,不训练。 因为这一节要讲的是「结构」,不是「怎么学」。 四段:先把参数账单摆出来;再把形状变化一步步打印; 然后用随机权重看 4 个卷积核的输出长什么样;最后说明这四个核此刻还什么都不是,要等训练。 第 3.6 节会把训练补上,那时这四个核才会变成「对分类有用」的模板。

六、动手 Demo:一次前向,形状怎么变

这段代码画一张 8 乘 8 的数字图,数清两种结构的参数量,然后把一次前向的每一步形状都打印出来, 最后展示 4 个随机卷积核的输出。 重点看三处。第二段 2410 对 410;第三段形状从 (12,8,8) 一路变到 (12,10); 第四段四个核的输出互不相同。

# -*- 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):
    """画一个七段数码管数字,返回 size 乘 size 的 0/1 矩阵。"""
    SEG = {0: 'abcdef', 1: 'bc', 2: 'abged', 3: 'abgcd', 4: 'fgbc',
           5: 'afgcd', 6: 'afgecd', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg'}
    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 im2col(X, k=3):
    """把每张图的每个 k 乘 k 窗口拉成一列,得到 (n, 窗口数, k*k)。"""
    n, H, W = X.shape
    oh, ow = H - k + 1, W - k + 1
    out = np.empty((n, oh * ow, k * k))
    for i in range(k):
        for j in range(k):
            out[:, :, i * k + j] = X[:, i:i + oh, j:j + ow].reshape(n, -1)
    return out, oh, ow


def show(mat, title):
    print('  ' + title)
    for row in mat:
        print('    ' + ' '.join('#' if v > 0.5 else '.' for v in row))


print('[1] 先看一张图:8 乘 8 的七段数码管')
g = one_digit(2)
show(g, '数字 2 的像素(# 亮,. 暗)')
print('  这张图有 64 个像素。全连接网络拿到的是 64 个互不相干的数——')
print('  它不知道第 1 个数和第 2 个数是挨着的,也不知道第 8 个数和第 9 个数是上下关系')
print('')

print('[2] 两种结构的参数账单')
print('  结构                                                      参数量')
n_fc = 64 * 32 + 32 + 32 * 10 + 10
print('  ' + pad('全连接 64 -> 32 -> 10', 58) + '%d' % n_fc)
n_cv = (3 * 3 + 1) * 4 + (36 * 10 + 10)
print('  ' + pad('卷积 4 核 3x3 -> 池化 2x2 -> 全连接 36 到 10 类', 58) + '%d' % n_cv)
print('')
print('  卷积版只有全连接版的 %.1f%%,参数少了 %.1f 倍' % (100.0 * n_cv / n_fc, n_fc / n_cv))
print('  少的不是「表达力」,是「重复」:同一个 3x3 核在 36 个位置上用同一组 9 个权重')
print('')

print('[3] 一次前向,看形状怎么变')
X = np.stack([one_digit(k) for k in range(10)] + [one_digit(0)] * 2)
rg = np.random.default_rng(0)
Wk = rg.normal(0.0, 0.5, (3, 3, 4))
bk = np.zeros(4)
P, oh, ow = im2col(X, 3)
Z = (P @ Wk.reshape(9, 4) + bk).reshape(len(X), oh, ow, 4)
A = np.maximum(Z, 0.0)
n, h1, w1, F = A.shape
h2, w2 = h1 // 2, w1 // 2
R = A[:, :h2 * 2, :w2 * 2, :].reshape(n, h2, 2, w2, 2, F)
M = R.max(axis=(2, 4))
flat = M.reshape(n, -1)
Wf = rg.normal(0.0, 0.1, (flat.shape[1], 10))
logits = flat @ Wf

print('  步骤                 形状')
print('  输入图像             (%d, 8, 8)' % n)
print('  拆窗(im2col)       (%d, %d, %d)' % (len(X), P.shape[1], P.shape[2]))
print('  卷积 + 偏置          (%d, %d, %d, %d)' % Z.shape)
print('  ReLU                 (%d, %d, %d, %d)' % A.shape)
print('  最大池化 2x2         (%d, %d, %d, %d)' % M.shape)
print('  摊平                 (%d, %d)' % flat.shape)
print('  全连接               (%d, 10)' % n)
print('')

print('[4] 卷积核在找什么:四个随机核的输出不一样')
for f in range(4):
    print('  核 %d(在数字 0 上的输出):' % f)
    for row in Z[0, :, :, f]:
        print('    ' + ' '.join('%+.1f' % v for v in row))
print('')
print('  四个核的权重是随机初始化的,所以它们此刻在找什么还看不出来')
print('  训练会把这 36 个数(4 个核各 9 个)调成「对分类有用」的样子')
print('  层数浅的时候人能看出它们在找边缘、拐角;层数深了就看不出来了')

实跑输出:

--------------------------------------------------------------------
卷积神经网络的结构:一次前向,形状怎么变
--------------------------------------------------------------------
[1] 先看一张图:8 乘 8 的七段数码管
  数字 2 的像素(# 亮,. 暗)
    . . . . . . . .
    . . # # # # # .
    . . . . . # . .
    . . . . . # . .
    . . # # # # . .
    . # # # . . . .
    . . . . . . . .
    . . # # # # . .
  这张图有 64 个像素。全连接网络拿到的是 64 个互不相干的数——
  它不知道第 1 个数和第 2 个数是挨着的,也不知道第 8 个数和第 9 个数是上下关系

[2] 两种结构的参数账单
  结构                                                      参数量
  全连接 64 -> 32 -> 10                                     2410
  卷积 4 核 3x3 -> 池化 2x2 -> 全连接 36 到 10 类           410

  卷积版只有全连接版的 17.0%,参数少了 5.9 倍
  少的不是「表达力」,是「重复」:同一个 3x3 核在 36 个位置上用同一组 9 个权重

[3] 一次前向,看形状怎么变
  步骤                 形状
  输入图像             (12, 8, 8)
  拆窗(im2col)       (12, 36, 9)
  卷积 + 偏置          (12, 6, 6, 4)
  ReLU                 (12, 6, 6, 4)
  最大池化 2x2         (12, 3, 3, 4)
  摊平                 (12, 36)
  全连接               (12, 10)

[4] 卷积核在找什么:四个随机核的输出不一样
  核 0(在数字 0 上的输出):
    -0.4 -0.2 -1.3 -1.1 -1.7 -1.0
    -0.7 -2.1 -2.0 -1.9 -1.1 -0.9
    -0.6 -0.6 -0.2 -0.4 -0.8 -0.6
    -0.3 +0.1 +0.2 -0.0 -0.8 -0.6
    -0.3 -1.5 -1.4 -1.7 -0.8 -0.6
    -0.7 -0.9 -0.1 -0.2 -0.3 -0.6
  核 1(在数字 0 上的输出):
    +1.1 +1.0 +0.6 +0.7 +0.5 -0.2
    -0.1 -0.0 -0.8 +0.3 -0.6 +0.1
    -0.5 -0.5 +0.1 +0.3 +0.1 -0.1
    +0.4 +0.4 +0.2 +0.4 +0.1 -0.1
    +0.5 +0.4 -0.3 +0.2 +0.1 -0.1
    -0.2 -0.1 +0.5 +0.4 +0.2 -0.1
  核 2(在数字 0 上的输出):
    -0.7 -0.9 -1.6 -1.0 -1.3 -0.8
    -0.4 -0.4 +0.2 -0.2 +0.4 -0.0
    +0.3 +0.7 +1.0 -0.2 +0.4 -0.7
    -0.4 -0.8 -0.9 -0.9 +0.4 -0.7
    -0.1 -0.7 -0.4 -1.2 +0.4 -0.7
    +0.4 +0.3 +0.2 -1.1 -0.0 -0.7
  核 3(在数字 0 上的输出):
    +0.2 +0.3 -0.2 +0.0 +0.2 -0.3
    +0.7 +0.8 +0.7 +0.5 +1.0 -0.3
    +0.5 +0.5 +0.5 +0.4 +0.9 -0.8
    +0.1 -0.4 -0.6 -0.1 +0.9 -0.8
    +0.7 +0.3 +0.2 +0.0 +0.9 -0.8
    +0.7 +0.6 +0.1 -0.1 +0.4 -0.8

  四个核的权重是随机初始化的,所以它们此刻在找什么还看不出来
  训练会把这 36 个数(4 个核各 9 个)调成「对分类有用」的样子
  层数浅的时候人能看出它们在找边缘、拐角;层数深了就看不出来了

第一段那张 # 和 . 的图不是装饰。 看第 2 行连着 5 个 #——这就是「横笔画」;第 6 列连着 5 个 #——这是「竖笔画」。 卷积核要找的就是这种局部图案。 第二段的 410 是怎么来的:4 个核各 9 个权重加 1 个偏置等于 40, 再加全连接 36 乘 10 加 10 等于 370。 注意全连接那一层占了 90%,说明真正的参数大头在最后。 第三段那个 36 是 6 乘 6 的窗口数。 如果核是 5 乘 5,窗口数就变成 16,后面的全连接参数会少一半多。 核越大、看到范围越大,但窗口数越少——这是个权衡。 第四段四个核的输出差别很大,因为它们权重不同。 但此刻它们只是在做「随机的加权求和」,找不出任何有意义的东西。 训练会把它们调成「某些位置有响应」的样子——第 3.6 节会用梯度来调。 代码里 im2col 是关键的一步。 它把「滑动窗口」这个循环展开成一次矩阵乘法:每个窗口变成一行,9 个像素变成 9 列。 所有框架里的卷积都是这么实现的——不是真的在滑窗,而是先重排再做矩阵乘。

30 秒实验

  1. 把核的个数从 4 改成 8,重跑第二段。 卷积参数从 40 涨到 80,但后面全连接的输入还是 36(因为池化之后的尺寸由核大小决定, 不由核个数决定),所以全连接那 370 个参数也跟着变成 72 乘 10 加 10 等于 730。 通道数一变,全连接层要跟着变。
  2. 把核从 3 乘 3 改成 5 乘 5,重跑第三段。 卷积输出会变成 4 乘 4,池化后是 2 乘 2,摊平只有 16 个数。 全连接的参数从 370 掉到 170。
  3. 把 im2col 的 k 改成 1,重跑第三段。输出尺寸不变(8 乘 8),窗口数变成 64。 这就是 1x1 卷积,第 3.4 节会专门讲它。

常见误区

  1. 「卷积层比全连接层强,所以应该全用卷积」。 卷积强在「利用邻接结构」和「省参数」,前提是输入真的有邻接结构。 把一张表里毫无关系的几列喂给卷积,它只是白算。
  2. 「卷积就是图像滤波,和神经网络没关系」。 滤波器的系数在传统图像处理里是人设计的,在卷积网络里是训练出来的。 这一个差别把它从工具变成了可学习的部件。
  3. 「核越多越好」。 核越多,通道越多,后面全连接的输入越大、参数越多。 而且核之间会冗余——很多核学到的模板几乎一样。
  4. 「池化层也有参数要学」。 池化没有任何参数,它只是「每块取最大」或「每块求平均」。 所以它不增加模型容量,只改变信息的组织方式。
自测三问(点开对答案) 1. 为什么卷积网络的参数比全连接少那么多? —— 因为权值共享。一个 3 乘 3 的核只有 9 个权重,却在 36 个位置各用一次; 全连接则为每个「输入与输出的配对」都配一个独立权重。 实测两层结构的参数是 2410 对 410,差 5.9 倍。 2. 一次前向里,尺寸和通道各是怎么变的? —— 尺寸一路降:8 到 6 到 3;通道一路升:1 到 4 再到 10 类。 降尺寸是为了减少后面的计算量,升通道是为了让不同特征各占一个通道。 这就是「先宽后窄」的节奏,和全连接网络的「一直宽」不同。 3. 卷积层、池化层、全连接层各管什么? —— 卷积层找局部模式(一个核就是一个模板);池化层降尺寸,顺带换来小范围平移不变; 全连接层把特征图变成判断,它不管空间结构,所以只能放在最后。

结构看完了,接下来把三个部件拆开。 先看卷积层:一个 3 乘 3 的窗口在图上滑动,到底在算什么? 为什么换一组 9 个权重,输出的图就完全不同?

相关笔记