卷积就是一个 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 秒实验
- 把
K['竖线检测']的符号整体反过来,重跑。输出会变成原来的相反数。 因为卷积是线性运算,核乘负一,输出就乘负一。 - 把模糊核改成九个数都是 1(不除以 9),重跑。输出会放大 9 倍,最大值到 5 左右。 权重和不再等于 1,它就不再是「平均」了。
- 把
conv1里的k.shape[0]改用 5 乘 5 的核(自己造一个),重跑。输出会变成 4 乘 4。 手工验证一下公式 (8 减 5) 除以 1 加 1 等于 4。
常见误区
- 「卷积就是矩阵乘法,所以没什么特别」。 它是「带约束的矩阵乘法」:大部分权重被强制为 0(只连邻域), 剩下的权重被强制相等(共享)。 约束就是先验,先验就是省参数。
- 「核越大越好,看到的东西越多」。 核大则窗口少、参数多、计算贵。 今天更常见的做法是堆两层 3 乘 3,等效视野是 5 乘 5,但参数只有 18 个而不是 25 个。
- 「卷积要做翻转,因为数学定义里有翻转」。 深度学习里的「卷积」其实是互相关,不翻转。 因为核是学出来的,翻不翻转只是换个记法,不影响能力。
- 「填充会引入假信息,所以不该用」。 不填充的话每过一层尺寸就缩,几层之后图就没了。 填 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 还是太大。 下一步是把每个小块的响应压成一个数——这就是池化层。 它不学参数,却换来了「挪一点也认得出」。
- ☐ (本节完,下一节 3.3 池化层)