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

训练卷积网络和第 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 秒实验

  1. 把卷积核个数从 8 改成 4,重跑第三段。测试准确率会掉到 0.93 附近,参数量降到 410。 通道太少,能表达的特征种类不够。
  2. 把 im2col 里循环的 i * k + j 改成 j * k + i,重跑第二段。梯度对账会整片对不上。 拆窗的维度顺序和核的展平顺序必须一致,这是最容易错的一处。
  3. 把池化的 mask 改成「所有位置都分到梯度」(相当于平均池化的反传),重跑第三段。 训练还能跑,但收敛会变慢、最终准确率略低。 因为前向是最大池化,反向按平均传,梯度和前向不一致。

常见误区

  1. 「卷积网络的训练方法和全连接不一样」。 完全一样:前向、算损失、反向、更新。 只是「一层里怎么算」不同,链式法则一步没改。
  2. 「池化层没有参数,所以不用管它的反向」。 它没有参数,但它要「把梯度传回去」。 传错位置,前面所有层的梯度就都错了。
  3. 「梯度对账只在写框架的时候需要」。 只要你手写过一次卷积或池化,就该对账。 实测漏乘一个掩码,梯度能差几十倍,而程序不会报错。
  4. 「训练准确率到 1.0 就该停了」。 准确率是离散的,到 1.0 之后损失还在降。 继续训可以让模型更自信,但如果测试集不涨,就是过拟合的开始。
自测三问(点开对答案) 1. 卷积核的梯度怎么算?为什么是「拆窗后的输入」? —— 前向是「拆窗后的输入 P」乘「核 Wk」得到输出,所以按矩阵乘法的梯度规则, 核的梯度等于 P 的转置乘该层的误差,再按样本求和。 本质就是上一章那条规则,只是输入换成了拆窗后的矩阵。 2. 池化的反向传播要注意什么? —— 最大池化的梯度只回到当时取到最大值的位置,其余位置是零,所以前向要记下 mask。 如果前向用最大池化、反向按平均传,梯度就和前向不一致,训练会变慢。 3. 为什么训练准确率到 1.0000 之后损失还在降? —— 因为准确率只看 argmax 对不对,是离散的;损失看的是正确类的概率,是连续的。 模型可以把已经分对的样本分得更自信,损失继续降,但准确率不再变。

第 3 章的部件都装起来了,也训练通了。 最后一节把卷积网络和全连接网络放在一起比,看参数少的那个到底赢在哪。 答案可能和你想的不一样——不在准确率上。

相关笔记