同样做 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 秒实验
- 把核的个数从 4 改成 8,重跑第二段。 卷积参数从 40 涨到 80,但后面全连接的输入还是 36(因为池化之后的尺寸由核大小决定, 不由核个数决定),所以全连接那 370 个参数也跟着变成 72 乘 10 加 10 等于 730。 通道数一变,全连接层要跟着变。
- 把核从 3 乘 3 改成 5 乘 5,重跑第三段。 卷积输出会变成 4 乘 4,池化后是 2 乘 2,摊平只有 16 个数。 全连接的参数从 370 掉到 170。
- 把
im2col的k改成 1,重跑第三段。输出尺寸不变(8 乘 8),窗口数变成 64。 这就是 1x1 卷积,第 3.4 节会专门讲它。
常见误区
- 「卷积层比全连接层强,所以应该全用卷积」。 卷积强在「利用邻接结构」和「省参数」,前提是输入真的有邻接结构。 把一张表里毫无关系的几列喂给卷积,它只是白算。
- 「卷积就是图像滤波,和神经网络没关系」。 滤波器的系数在传统图像处理里是人设计的,在卷积网络里是训练出来的。 这一个差别把它从工具变成了可学习的部件。
- 「核越多越好」。 核越多,通道越多,后面全连接的输入越大、参数越多。 而且核之间会冗余——很多核学到的模板几乎一样。
- 「池化层也有参数要学」。 池化没有任何参数,它只是「每块取最大」或「每块求平均」。 所以它不增加模型容量,只改变信息的组织方式。
自测三问(点开对答案)
1. 为什么卷积网络的参数比全连接少那么多? —— 因为权值共享。一个 3 乘 3 的核只有 9 个权重,却在 36 个位置各用一次; 全连接则为每个「输入与输出的配对」都配一个独立权重。 实测两层结构的参数是 2410 对 410,差 5.9 倍。 2. 一次前向里,尺寸和通道各是怎么变的? —— 尺寸一路降:8 到 6 到 3;通道一路升:1 到 4 再到 10 类。 降尺寸是为了减少后面的计算量,升通道是为了让不同特征各占一个通道。 这就是「先宽后窄」的节奏,和全连接网络的「一直宽」不同。 3. 卷积层、池化层、全连接层各管什么? —— 卷积层找局部模式(一个核就是一个模板);池化层降尺寸,顺带换来小范围平移不变; 全连接层把特征图变成判断,它不管空间结构,所以只能放在最后。结构看完了,接下来把三个部件拆开。 先看卷积层:一个 3 乘 3 的窗口在图上滑动,到底在算什么? 为什么换一组 9 个权重,输出的图就完全不同?
- ☐ (本节完,下一节 3.2 卷积层)