训练卷积网络和第 2 章训练全连接网络完全一样:前向、算损失、反向、更新。 变的只是「一层里怎么算」,反向传播的链式法则一步没改。 8 个 3 乘 3 核加一层全连接,810 个参数,100 轮之后训练准确率 1.0000、测试 0.9912。 六个参数的解析梯度和数值梯度对账,相对误差都在 1e-08 或更小。 最容易写错的是池化的反向:梯度只能回到「当时被选中的那个位置」。
前五节讲了结构、卷积、池化、全连接、输出层,但一次都没训练。 这一节补上:把 810 个参数真正用梯度调出来。 反向传播要穿过四种层:全连接、池化、ReLU、卷积。 前三种在上一章都见过,卷积是新的——但它的梯度也是链式法则,只是形状处理麻烦。 判断写得对不对,还是老办法:数值梯度对账。
一、网络结构
数据:450 张 8 乘 8 数字图,337 张训练、113 张测试,噪声标准差 0.30。
| 层 | 形状变化 | 参数量 |
|---|---|---|
| 卷积 8 个 3 乘 3 核 | (n,8,8) 到 (n,6,6,8) | 8 乘 9 加 8 等于 80 |
| ReLU | 形状不变 | 0 |
| 最大池化 2 乘 2 | (n,6,6,8) 到 (n,3,3,8) | 0 |
| 摊平 | (n,3,3,8) 到 (n,72) | 0 |
| 全连接 | (n,72) 到 (n,10) | 72 乘 10 加 10 等于 730 |
| 合计 810 个参数。 | ||
| 对比第 2 章那个全连接网络:2410 个参数。 | ||
| 同一份数据上,卷积版的准确率还略高一点——这是下一节要展开说的。 |
二、反向传播要穿过四种层
从后往前数: 全连接层:和上一章一样,权重梯度是「输入转置乘误差」。 池化层:梯度只回到当时取到最大值的位置,其余三个位置是 0。 所以要先把 mask 记下来。 ReLU:梯度只回到正值的位置,负值的位置是 0。 这和第 2 章的写法一模一样。 卷积层:这是唯一需要想一下的地方。 核的梯度等于「拆窗之后的输入」和「该层的误差」做一次矩阵乘。
拆窗后的输入 P (n, 36, 9)
卷积层的误差 dZ (n, 36, 8)
核的梯度 dWk = P 转置 @ dZ,再按样本求和 -> (9, 8)
偏置的梯度 dbk = dZ 按位置和样本求和 -> (8,)
为什么是「拆窗后的输入」?因为前向时就是 P 乘 Wk 得到输出。 矩阵乘法的梯度规则:输出对权重的导数等于输入。 所以反向时把角色换过来就行——这是上一章那条规则的原样套用。
三、对账:六个参数
| 参数 | 解析梯度 | 数值梯度 | 相对误差 |
|---|---|---|---|
| Wk(0,0,0) | 0.005943458 | 0.005943458 | 2.34e-08 |
| Wk(1,2,1) | 0.028421827 | 0.028421827 | 1.12e-09 |
| Wk(2,1,3) | -0.131525266 | -0.131525266 | 4.22e-10 |
| Wf(0,0) | 0.013968503 | 0.013968503 | 2.75e-09 |
| Wf(5,7) | 0.241891440 | 0.241891440 | 8.26e-12 |
| Wf(20,9) | 0.000243141 | 0.000243141 | 7.92e-08 |
| 六个参数的相对误差都在 1e-08 或更小。 | |||
| 最后两个绝对值很小(0.0002 那一级),误差相对就大一点,这是差分的正常现象。 | |||
| 这一步非做不可。 | |||
| 卷积反向里最容易错的是三处:拆窗的维度顺序、池化 mask 的广播形状、卷积梯度的求和维度。 | |||
| 任何一处错了,程序都不报错,只是训练变慢或者精度上不去。 |
四、训练过程
小批量 32、学习率 0.1、100 轮:
| 轮次 | 训练损失 | 训练准确率 |
|---|---|---|
| 10 | 0.3014 | 0.9555 |
| 20 | 0.1370 | 0.9881 |
| 30 | 0.0756 | 0.9911 |
| 50 | 0.0338 | 1.0000 |
| 70 | 0.0213 | 1.0000 |
| 100 | 0.0136 | 1.0000 |
| 100 轮耗时 0.68 秒,测试准确率 0.9912(113 张里错 1 张)。 | ||
| 注意第 50 轮训练准确率就到 1.0000 了,之后损失还在降但准确率不动。 | ||
| 这就是「准确率是离散指标、损失是连续指标」的差别:损失能继续告诉模型「还不够自信」。 |
五、和第 2 章的关系
前向:卷积、ReLU、池化、摊平、全连接、softmax。 误差:softmax 减目标,再除以批大小。 反向:全连接、池化的掩码、ReLU 的掩码、卷积核的梯度。 更新:小批量梯度下降。 第 2 章那三步一步没变。 变的只是「一层里怎么算」——而这是卷积网络唯一的新东西。 所以理解了全连接网络的反向传播,卷积网络只是多了两个「形状处理」的麻烦:拆窗和池化的 mask。 数学上没有任何新原理。
六、动手 Demo:卷积核的梯度也要对账
这段代码手写一个完整的小卷积网络:8 个 3 乘 3 核、ReLU、最大池化、全连接; 先拿 8 个样本做梯度对账,再训练 100 轮,每 10 轮打印一次损失和准确率。 重点看三处。第一段 810 个参数怎么分;第二段六个参数的相对误差; 第三段第 50 轮训练准确率就到 1.0000、最终测试 0.9912。
# -*- 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 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 init(seed=0, nf=4):
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 forward(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, 'A': A, 'R': R, 'M': M, 'flat': flat,
'oh': oh, 'ow': ow, 'h2': h2, 'w2': w2, 'nf': nf}
def softmax(S):
S = S - S.max(axis=1, keepdims=True)
E = np.exp(S)
return E / E.sum(axis=1, keepdims=True)
def loss_of(p, X, T):
P = softmax(forward(p, X))
return float(-np.mean(np.log(np.maximum((P * T).sum(axis=1), 1e-12))))
def backward(p, X, T):
S, c = forward(p, X, keep=True)
n = len(X)
dS = (softmax(S) - T) / n
g = {}
g['Wf'] = c['flat'].T @ dS
g['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, :])
dR = mask * dflat[:, :, None, :, None, :]
dA = dR.reshape(n, c['oh'], c['ow'], c['nf'])
dZ = dA * (c['Zc'] > 0.0)
dZ2 = dZ.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 num_grad(p, X, T, key, idx):
old = p[key][idx]
p[key][idx] = old + 1e-6
lp = loss_of(p, X, T)
p[key][idx] = old - 1e-6
lm = loss_of(p, X, T)
p[key][idx] = old
return (lp - lm) / 2e-6
X, y = digits(45, 8, seed=3, noise=0.30)
C = 10
T = np.zeros((len(y), C))
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:]
p = init(0, 8)
g = backward(p, Xtr[:8], Ttr[:8])
print('[1] 网络结构:卷积 8 个 3x3 核 -> ReLU -> 最大池化 2x2 -> 全连接 72 到 10 类')
print(' 数据 %d 张 8 乘 8 数字图,%d 张训练、%d 张测试,噪声标准差 0.30'
% (len(y), NTR, len(y) - NTR))
print(' 参数量:卷积核 %d 个,全连接 %d 个,合计 %d 个'
% (p['Wk'].size + p['bk'].size, p['Wf'].size + p['bf'].size,
p['Wk'].size + p['bk'].size + p['Wf'].size + p['bf'].size))
print('')
print('[2] 卷积核的梯度对不对:拿 8 个样本做对账')
print(' 参数 解析梯度 数值梯度 相对误差')
for idx in ((0, 0, 0), (1, 2, 1), (2, 1, 3)):
ana = g['Wk'][idx]
num = num_grad(p, Xtr[:8], Ttr[:8], 'Wk', idx)
rel = abs(ana - num) / max(1e-12, abs(ana) + abs(num))
print(' ' + pad('Wk%s' % (idx,), 16) + pad('%.9f' % ana, 16)
+ pad('%.9f' % num, 16) + '%.2e' % rel)
for idx in ((0, 0), (5, 7), (20, 9)):
ana = g['Wf'][idx]
num = num_grad(p, Xtr[:8], Ttr[:8], 'Wf', idx)
rel = abs(ana - num) / max(1e-12, abs(ana) + abs(num))
print(' ' + pad('Wf%s' % (idx,), 16) + pad('%.9f' % ana, 16)
+ pad('%.9f' % num, 16) + '%.2e' % rel)
print('')
print(' 六个参数的相对误差都在 1e-08 或更小,绝对值小的那几个误差稍大是正常的。')
print(' 这说明卷积和池化的反向传播都写对了。')
print(' 池化的反向最容易写错:梯度只能回到「当时被选中的那个位置」,其它位置是零')
print('')
print('[3] 训练 100 轮,看损失和准确率')
def acc(p, X, y):
return float(np.mean(np.argmax(softmax(forward(p, X)), axis=1) == y))
rg = np.random.default_rng(0)
t0 = time.perf_counter()
for ep in range(100):
idx = rg.permutation(NTR)
for s in range(0, NTR, 32):
j = idx[s:s + 32]
gg = backward(p, Xtr[j], Ttr[j])
for k in gg:
p[k] = p[k] - 0.1 * gg[k]
if (ep + 1) % 10 == 0:
print(' 第 %3d 轮:训练损失 %.4f,训练准确率 %.4f'
% (ep + 1, loss_of(p, Xtr, Ttr), acc(p, Xtr, ytr)))
dt = time.perf_counter() - t0
print('')
print(' 耗时 %.2f 秒;测试准确率 %.4f(%d 张)' % (dt, acc(p, Xte, yte), len(yte)))
print('')
print('[4] 训练方法和全连接网络完全一样')
print(' 前向:卷积、ReLU、池化、摊平、全连接、softmax')
print(' 误差:softmax 减目标,再除以批大小')
print(' 反向:全连接 -> 池化的掩码 -> ReLU 的掩码 -> 卷积核的梯度')
print(' 更新:小批量梯度下降')
print(' 第 2 章那三步一步没变,变的只是「一层里怎么算」。')
实跑输出:
--------------------------------------------------------------------
神经网络的训练方法:卷积核的梯度也要对账
--------------------------------------------------------------------
[1] 网络结构:卷积 8 个 3x3 核 -> ReLU -> 最大池化 2x2 -> 全连接 72 到 10 类
数据 450 张 8 乘 8 数字图,337 张训练、113 张测试,噪声标准差 0.30
参数量:卷积核 80 个,全连接 730 个,合计 810 个
[2] 卷积核的梯度对不对:拿 8 个样本做对账
参数 解析梯度 数值梯度 相对误差
Wk(0, 0, 0) 0.005943458 0.005943458 2.34e-08
Wk(1, 2, 1) 0.028421827 0.028421827 1.12e-09
Wk(2, 1, 3) -0.131525266 -0.131525266 4.22e-10
Wf(0, 0) 0.013968503 0.013968503 2.75e-09
Wf(5, 7) 0.241891440 0.241891440 8.26e-12
Wf(20, 9) 0.000243141 0.000243141 7.92e-08
六个参数的相对误差都在 1e-08 或更小,绝对值小的那几个误差稍大是正常的。
这说明卷积和池化的反向传播都写对了。
池化的反向最容易写错:梯度只能回到「当时被选中的那个位置」,其它位置是零
[3] 训练 100 轮,看损失和准确率
第 10 轮:训练损失 0.3014,训练准确率 0.9555
第 20 轮:训练损失 0.1370,训练准确率 0.9881
第 30 轮:训练损失 0.0756,训练准确率 0.9911
第 40 轮:训练损失 0.0513,训练准确率 0.9941
第 50 轮:训练损失 0.0338,训练准确率 1.0000
第 60 轮:训练损失 0.0374,训练准确率 0.9941
第 70 轮:训练损失 0.0213,训练准确率 1.0000
第 80 轮:训练损失 0.0183,训练准确率 1.0000
第 90 轮:训练损失 0.0154,训练准确率 1.0000
第 100 轮:训练损失 0.0136,训练准确率 1.0000
耗时 0.66 秒;测试准确率 0.9912(113 张)
[4] 训练方法和全连接网络完全一样
前向:卷积、ReLU、池化、摊平、全连接、softmax
误差:softmax 减目标,再除以批大小
反向:全连接 -> 池化的掩码 -> ReLU 的掩码 -> 卷积核的梯度
更新:小批量梯度下降
第 2 章那三步一步没变,变的只是「一层里怎么算」。
第一段那 80 和 730 的对比很说明问题。
卷积层只有 80 个参数,全连接层有 730 个——参数大头永远在最后。
所以「压缩模型」的常规做法是先砍全连接,不是砍卷积。
第二段的对账只用了 8 个样本。
样本少,数值微分的每一步都看得清;样本多反而没必要——只要 6 个参数对上了,整段代码就是对的。
第三段第 50 轮就到了 1.0000,之后一路平。
这不是过拟合的信号,因为测试集也是 0.9912。
它只是说明 337 张图被 810 个参数完全记住了,而这在这个任务上不算过分。
第四段那四行要背下来。
它们说明卷积网络的训练流程和全连接网络完全同构,所以「学会了 CNN」不需要重学一遍反向传播。
代码里 mask = (c['R'] == c['M'][:, :, None, :, None, :]) 这一行是池化反传的关键。
它用「等于最大值」来判断当时谁被选中。
如果块里有并列的最大值,两个位置都会分到梯度——这在实践中很少见,严格做法是只给第一个。
30 秒实验
- 把卷积核个数从 8 改成 4,重跑第三段。测试准确率会掉到 0.93 附近,参数量降到 410。 通道太少,能表达的特征种类不够。
- 把
im2col里循环的i * k + j改成j * k + i,重跑第二段。梯度对账会整片对不上。 拆窗的维度顺序和核的展平顺序必须一致,这是最容易错的一处。 - 把池化的
mask改成「所有位置都分到梯度」(相当于平均池化的反传),重跑第三段。 训练还能跑,但收敛会变慢、最终准确率略低。 因为前向是最大池化,反向按平均传,梯度和前向不一致。
常见误区
- 「卷积网络的训练方法和全连接不一样」。 完全一样:前向、算损失、反向、更新。 只是「一层里怎么算」不同,链式法则一步没改。
- 「池化层没有参数,所以不用管它的反向」。 它没有参数,但它要「把梯度传回去」。 传错位置,前面所有层的梯度就都错了。
- 「梯度对账只在写框架的时候需要」。 只要你手写过一次卷积或池化,就该对账。 实测漏乘一个掩码,梯度能差几十倍,而程序不会报错。
- 「训练准确率到 1.0 就该停了」。 准确率是离散的,到 1.0 之后损失还在降。 继续训可以让模型更自信,但如果测试集不涨,就是过拟合的开始。
自测三问(点开对答案)
1. 卷积核的梯度怎么算?为什么是「拆窗后的输入」? —— 前向是「拆窗后的输入 P」乘「核 Wk」得到输出,所以按矩阵乘法的梯度规则, 核的梯度等于 P 的转置乘该层的误差,再按样本求和。 本质就是上一章那条规则,只是输入换成了拆窗后的矩阵。 2. 池化的反向传播要注意什么? —— 最大池化的梯度只回到当时取到最大值的位置,其余位置是零,所以前向要记下 mask。 如果前向用最大池化、反向按平均传,梯度就和前向不一致,训练会变慢。 3. 为什么训练准确率到 1.0000 之后损失还在降? —— 因为准确率只看 argmax 对不对,是离散的;损失看的是正确类的概率,是连续的。 模型可以把已经分对的样本分得更自信,损失继续降,但准确率不再变。第 3 章的部件都装起来了,也训练通了。 最后一节把卷积网络和全连接网络放在一起比,看参数少的那个到底赢在哪。 答案可能和你想的不一样——不在准确率上。
- ☐ (本节完,下一节 3.7 小结)