同一份数据、同样 100 轮:全连接 2410 个参数、0.12 秒、准确率 0.9823; 卷积 810 个参数、0.56 秒、0.9912。 参数少三倍,准确率还略高——但真正的差距不在准确率上。 把测试图整体平移 1 像素:全连接掉到 0.3894,卷积还有 0.5487。 平移 2 像素时卷积反而更差(0.1062 对 0.2920),因为正好跨过了一个池化窗口。
这一章从「为什么不能摊平」讲到「卷积核的梯度怎么算」,最后要把账算清。 卷积网络的优势有两处:参数效率和局部平移鲁棒。 这两处都在这一节的表里能看到数字。 但也要看到它的代价:同样 100 轮,卷积版慢 4.7 倍。 因为卷积的计算量比矩阵乘大,而这台机器上 numpy 的矩阵乘是优化过的。
一、参数与准确率
| 结构 | 参数量 | 训练耗时 | 测试准确率 |
|---|---|---|---|
| 全连接 64 到 32 到 10 | 2410 | 0.12 秒 | 0.9823 |
| 卷积 8 核,池化,全连接 | 810 | 0.56 秒 | 0.9912 |
| 参数少 3.0 倍,准确率高 0.0089——也就是 113 张测试图里少错 1 张。 | |||
| 这个差距很小,不能拿它当「卷积更好」的证据。 | |||
| 参数效率才是硬指标:同样 810 个参数的预算,卷积能堆更深的层、能吃更大的图, | |||
| 而全连接在 8 乘 8 上就已经把预算用掉三倍了。 |
二、平移鲁棒:差距真正拉开的地方
| 平移 | 全连接 | 卷积 |
|---|---|---|
| 0 像素 | 0.9823 | 0.9912 |
| 1 像素 | 0.3894 | 0.5487 |
| 2 像素 | 0.2920 | 0.1062 |
| 3 像素 | 0.0708 | 0.1504 |
| 两边都掉得很惨,因为图只有 8 像素宽,挪一格就有一整列被挤出画面。 | ||
| 信息是真的丢了,这跟模型结构无关。 | ||
| 1 像素时卷积明显更好(0.5487 对 0.3894)——因为它不依赖「特征出现在第几列」。 | ||
| 全连接把每个像素当成独立特征,位置一动,权重就全对不上了。 | ||
| 但 2 像素时卷积反而更差(0.1062 对 0.2920)。 | ||
| 2 像素正好是一个池化窗口的宽度,块的内容整个换掉,池化救不了。 | ||
| 这时候全连接的「逐位置学习」反而还有点用——它至少记住了一些全局的亮度分布。 | ||
| 所以「平移不变」要说清范围:一个窗口之内有效,出了窗口一样崩。 | ||
| 要真正做平移鲁棒,得靠数据增强(训练时随机平移),不是靠结构。 |
三、代价:计算量
卷积版慢 4.7 倍。 原因有两个:一是拆窗要复制数据,二是卷积的乘法次数比同规模的全连接多。 8 个 3 乘 3 的核在 36 个位置上,一次前向要做 36 乘 9 乘 8 等于 2592 次乘加。 同样输入输出的全连接是 64 乘 72 等于 4608 次——看着更多,但矩阵乘走的是 BLAS,快得多。 而在 GPU 上这个账会反过来:卷积能用 cuDNN 的高度优化实现,全连接反而要单独写。 所以「卷积比全连接慢」这句话只在纯 numpy 的小规模实验里成立。
四、这一章的三句话
第一,卷积的核心是权值共享,不是「卷积」这个数学运算。 共享把 2304 个权重压成 9 个,压的倍数等于窗口数。 第二,池化换来的是「一个窗口之内」的平移不变。 出了窗口就失效,别把它当万能的。 第三,训练方法和第 2 章完全一样。 前向、算损失、反向、更新,一步没变;变的只是「一层里怎么算」。
五、下一章要去哪
第 2 章是有监督的、靠标签训练的网络。 第 3 章换了连接方式,但还是靠标签。 第 4 章要换掉的是「训练目标」:没有标签,怎么让网络自己学出一组好的表示。 那里的核心工具是能量函数和吉布斯采样——和第 2、3 章完全不同的思路。
六、动手 Demo:全连接与卷积,参数少三倍,挪一个像素见高下
这段代码在同一份数据上训练两个模型:一个全连接 64 到 32 到 10,一个卷积 8 核加池化加全连接; 然后比较参数量、耗时、准确率,最后把测试图整体平移 0 到 3 像素再各测一遍。 重点看三处。第一段 2410/0.12 秒/0.9823 对 810/0.56 秒/0.9912;第二段 0 到 3 像素那张表; 以及 2 像素那一行卷积反而更差。
# -*- coding: utf-8 -*-
import time
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 digits(n_per=45, size=8, seed=3, noise=0.30):
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)}
rg = np.random.default_rng(seed)
X, Y = [], []
for k in sorted(SEG.keys()):
for _ in range(n_per):
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
X.append(img + rg.normal(0.0, noise, (size, size)))
Y.append(k)
X = np.array(X)
Y = np.array(Y)
idx = rg.permutation(len(Y))
return X[idx], Y[idx]
def softmax(S):
S = S - S.max(axis=1, keepdims=True)
E = np.exp(S)
return E / E.sum(axis=1, keepdims=True)
def sm_loss(S, T):
P = softmax(S)
return float(-np.mean(np.log(np.maximum((P * T).sum(axis=1), 1e-12))))
# ---------------------------------------------------------------- 全连接
def mlp_init(seed=0):
rg = np.random.default_rng(seed)
return {'W1': rg.normal(0.0, np.sqrt(2.0 / 64), (64, 32)), 'b1': np.zeros(32),
'W2': rg.normal(0.0, np.sqrt(2.0 / 32), (32, 10)), 'b2': np.zeros(10)}
def mlp_fwd(p, X):
x = X.reshape(len(X), -1)
h = np.maximum(x @ p['W1'] + p['b1'], 0.0)
return (h @ p['W2'] + p['b2']), x, h
def mlp_grad(p, X, T):
S, x, h = mlp_fwd(p, X)
dS = (softmax(S) - T) / len(X)
g = {'W2': h.T @ dS, 'b2': dS.sum(axis=0)}
dh = (dS @ p['W2'].T) * (h > 0.0)
g['W1'] = x.T @ dh
g['b1'] = dh.sum(axis=0)
return g
# ---------------------------------------------------------------- 卷积
def im2col(X, k=3):
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 cnn_init(seed=0, nf=8):
rg = np.random.default_rng(seed)
return {'Wk': rg.normal(0.0, 0.5, (3, 3, nf)), 'bk': np.zeros(nf),
'Wf': rg.normal(0.0, np.sqrt(2.0 / (9 * nf)), (9 * nf, 10)), 'bf': np.zeros(10)}
def cnn_fwd(p, X, keep=False):
P, oh, ow = im2col(X, 3)
nf = p['Wk'].shape[2]
Zc = (P @ p['Wk'].reshape(9, nf) + p['bk']).reshape(len(X), oh, ow, nf)
A = np.maximum(Zc, 0.0)
h2, w2 = oh // 2, ow // 2
R = A[:, :h2 * 2, :w2 * 2, :].reshape(len(X), h2, 2, w2, 2, nf)
M = R.max(axis=(2, 4))
flat = M.reshape(len(X), -1)
S = flat @ p['Wf'] + p['bf']
if not keep:
return S
return S, {'P': P, 'Zc': Zc, 'R': R, 'M': M, 'flat': flat,
'oh': oh, 'ow': ow, 'h2': h2, 'w2': w2, 'nf': nf}
def cnn_grad(p, X, T):
S, c = cnn_fwd(p, X, keep=True)
n = len(X)
dS = (softmax(S) - T) / n
g = {'Wf': c['flat'].T @ dS, 'bf': dS.sum(axis=0)}
dflat = (dS @ p['Wf'].T).reshape(n, c['h2'], c['w2'], c['nf'])
mask = (c['R'] == c['M'][:, :, None, :, None, :])
dA = (mask * dflat[:, :, None, :, None, :]).reshape(n, c['oh'], c['ow'], c['nf'])
dZ2 = (dA * (c['Zc'] > 0.0)).reshape(n, c['oh'] * c['ow'], c['nf'])
g['Wk'] = np.einsum('npk,npf->kf', c['P'], dZ2).reshape(3, 3, c['nf'])
g['bk'] = dZ2.sum(axis=(0, 1))
return g
def shift(X, d):
if d == 0:
return X.copy()
out = np.zeros_like(X)
if d > 0:
out[:, :, d:] = X[:, :, :-d]
else:
out[:, :, :d] = X[:, :, -d:]
return out
def train(kind, Xtr, Ttr, epochs=100, lr=0.1, seed=0):
p = mlp_init(seed) if kind == 'mlp' else cnn_init(seed)
grad = mlp_grad if kind == 'mlp' else cnn_grad
fwd = mlp_fwd if kind == 'mlp' else cnn_fwd
rg = np.random.default_rng(0)
t0 = time.perf_counter()
for _ in range(epochs):
idx = rg.permutation(len(Xtr))
for s in range(0, len(Xtr), 32):
j = idx[s:s + 32]
g = grad(p, Xtr[j], Ttr[j])
for k in g:
p[k] = p[k] - lr * g[k]
return p, time.perf_counter() - t0
def acc(kind, p, X, y):
S = mlp_fwd(p, X)[0] if kind == 'mlp' else cnn_fwd(p, X)
return float(np.mean(np.argmax(softmax(S), axis=1) == y))
def npar(kind):
return 2410 if kind == 'mlp' else 810
X, y = digits(45, 8, seed=3, noise=0.30)
T = np.zeros((len(y), 10))
T[np.arange(len(y)), y] = 1.0
NTR = len(y) * 3 // 4
Xtr, Ttr, ytr = X[:NTR], T[:NTR], y[:NTR]
Xte, Tte, yte = X[NTR:], T[NTR:], y[NTR:]
print('[1] 两种结构,同一份数据、同样的 100 轮、同样的学习率 0.1、同样的小批量 32')
print(' 全连接:64 -> 32 -> 10,%d 个参数' % npar('mlp'))
print(' 卷积:8 个 3x3 核 -> ReLU -> 池化 2x2 -> 全连接 72 到 10,%d 个参数' % npar('cnn'))
print('')
p_mlp, t_mlp = train('mlp', Xtr, Ttr)
p_cnn, t_cnn = train('cnn', Xtr, Ttr)
print(' 结构 参数量 训练耗时 测试准确率')
print(' ' + pad('全连接', 9) + pad('%d' % npar('mlp'), 9) + pad('%.2f 秒' % t_mlp, 11)
+ '%.4f' % acc('mlp', p_mlp, Xte, yte))
print(' ' + pad('卷积', 9) + pad('%d' % npar('cnn'), 9) + pad('%.2f 秒' % t_cnn, 11)
+ '%.4f' % acc('cnn', p_cnn, Xte, yte))
print('')
print(' 卷积版参数少 %.1f 倍,准确率反而高一点。'
% (npar('mlp') / float(npar('cnn'))))
print(' 但这一点不能夸大——在这个任务上两者的差距只有一个样本左右。')
print('')
print('[2] 真正拉开差距的是「挪几个像素」')
print(' 把测试图整体横向平移,再看准确率:')
print(' 平移 全连接的准确率 卷积的准确率')
for d in (0, 1, 2, 3):
Xs = shift(Xte, d)
print(' ' + pad('%d 像素' % d, 9) + pad('%.4f' % acc('mlp', p_mlp, Xs, yte), 17)
+ '%.4f' % acc('cnn', p_cnn, Xs, yte))
print('')
print(' 1 像素时卷积明显好:0.5487 对 0.3894。但两边都掉得很惨。')
print(' 因为图只有 8 像素宽,挪一格就有一整列被挤出画面,信息是真的丢了。')
print(' 2 像素时卷积反而更差(0.1062 对 0.2920)——2 像素正好跨过一个池化窗口,')
print(' 窗口里的内容整个换掉,池化也救不了。')
print(' 所以「平移不变」是有范围的:一个窗口之内有效,出了窗口一样崩。')
print('')
print('[3] 这一章该带走的三句话')
print(' 第一,卷积的核心是权值共享,不是「卷积」这个数学运算。')
print(' 第二,池化换来的是「小范围平移不变」,而且这个范围就是一个窗口。')
print(' 第三,训练方法和第 2 章完全一样:前向、算损失、反向、更新。')
print('')
print(' 参数量:全连接 %d 对卷积 %d;这就是卷积能在同样预算下堆几十层的原因。'
% (npar('mlp'), npar('cnn')))
实跑输出:
--------------------------------------------------------------------
小结:全连接与卷积,参数少三倍,挪一个像素见高下
--------------------------------------------------------------------
[1] 两种结构,同一份数据、同样的 100 轮、同样的学习率 0.1、同样的小批量 32
全连接:64 -> 32 -> 10,2410 个参数
卷积:8 个 3x3 核 -> ReLU -> 池化 2x2 -> 全连接 72 到 10,810 个参数
结构 参数量 训练耗时 测试准确率
全连接 2410 0.12 秒 0.9823
卷积 810 0.55 秒 0.9912
卷积版参数少 3.0 倍,准确率反而高一点。
但这一点不能夸大——在这个任务上两者的差距只有一个样本左右。
[2] 真正拉开差距的是「挪几个像素」
把测试图整体横向平移,再看准确率:
平移 全连接的准确率 卷积的准确率
0 像素 0.9823 0.9912
1 像素 0.3894 0.5487
2 像素 0.2920 0.1062
3 像素 0.0708 0.1504
1 像素时卷积明显好:0.5487 对 0.3894。但两边都掉得很惨。
因为图只有 8 像素宽,挪一格就有一整列被挤出画面,信息是真的丢了。
2 像素时卷积反而更差(0.1062 对 0.2920)——2 像素正好跨过一个池化窗口,
窗口里的内容整个换掉,池化也救不了。
所以「平移不变」是有范围的:一个窗口之内有效,出了窗口一样崩。
[3] 这一章该带走的三句话
第一,卷积的核心是权值共享,不是「卷积」这个数学运算。
第二,池化换来的是「小范围平移不变」,而且这个范围就是一个窗口。
第三,训练方法和第 2 章完全一样:前向、算损失、反向、更新。
参数量:全连接 2410 对卷积 810;这就是卷积能在同样预算下堆几十层的原因。
第一段那 0.0089 的差距不要当成结论。
113 张测试图,错 2 张和错 1 张的差别而已。
真正的结论是「用三分之一的参数做到同一水平」。
第二段那张表才是这一章的重点。
1 像素时卷积好 0.16,2 像素时卷积差 0.19——一正一负,说明「平移不变」是有条件的。
把这个结论说成「卷积对平移鲁棒」是不准确的,要说「一个小窗口之内鲁棒」。
3 像素时两者都掉到 0.1 附近。
因为 8 像素宽的图挪 3 格,笔画的一半已经出画面了,任何模型都救不回来。
代码里两个模型用的是同一份训练数据、同一个种子、同样的批大小和学习率。
这是「控制变量」,否则比出来的数字没有意义。
唯一没控制的是耗时——那是结构决定的,没法控制。
注意 train 里两个模型的 grad 和 fwd 是按名字选的。
这就是「训练流程同构」在代码上的样子:换的是两个函数,不是整个循环。
30 秒实验
- 把卷积网络的
nf从 8 改成 4,重跑第一段。参数降到 410,测试准确率会掉到 0.93 附近。 参数少到某个点之后,通道不够用了。 - 把平移量扩到 4、5 像素,重跑第二段。两边都会掉到 0.1 以下,因为信息丢得太多。 这说明这个实验测的是「信息丢失」多于「结构鲁棒」。
- 把训练集先做随机平移增强(每个样本随机挪 0 到 2 像素),再重跑第二段。 两个模型的平移鲁棒性都会大幅提升,卷积那一栏更明显。 这说明数据增强比结构更能解决平移问题。
常见误区
- 「卷积网络准确率一定比全连接高」。 实测只差 0.0089,相当于 113 张里少错 1 张。 卷积赢在参数效率和局部鲁棒,不是赢在绝对精度。
- 「卷积对平移不敏感」。 只在一个池化窗口之内。实测平移 2 像素时卷积 0.1062,比全连接的 0.2920 还差。 要真正鲁棒,得靠数据增强。
- 「卷积比全连接算得快,因为参数少」。 参数少不等于算得快。 这次实验里卷积慢了 4.7 倍,因为拆窗和卷积的乘法次数更多。 在 GPU 上结论会不同,因为卷积有专门的优化库。
- 「池化层可以随便换成步长 2 的卷积」。 两者都能降采样,但卷积有参数、能学,池化不能。 换掉之后参数会变多、表达力会变强,代价是更容易过拟合。
自测三问(点开对答案)
1. 卷积网络相对全连接网络,优势在哪两处? —— 一是参数效率:810 个参数做到 2410 个参数的水平,因为权值共享。 二是局部平移鲁棒:平移 1 像素时准确率 0.5487 对 0.3894。 但准确率本身只差 0.0089,不能当主要优势。 2. 为什么平移 2 像素时卷积反而更差? —— 因为 2 像素正好是一个池化窗口的宽度。 窗口里的内容整个换掉,池化带来的「窗口内不变」就失效了。 全连接逐位置学习,反而还残留一点全局亮度分布的判断。 3. 卷积网络的训练和第 2 章有什么不同? —— 流程完全一样:前向、算损失、反向、更新,链式法则一步没改。 不同的只是「一层里怎么算」:多了拆窗、池化 mask 这两个形状处理。数学上没有新原理。第 2 章和第 3 章都有一个前提:有标签。 标签很贵——一张图要人标,一次标注几分钱到几块钱。 下一章换掉这个前提:没有标签,网络还能学什么?
- ☐ (本章完,下一节 4.1 Hopfield 神经网络)