📗 第2章 神经网络
📄 一句话概括

把第 2 章的零件装进一个网络:450 张数字图、64 到 32 到 10、2410 个参数。 只换隐层激活和损失函数,八种组合的测试准确率从 0.9823 一路掉到 0.2124。 最差的那组是 sigmoid 隐层配平方误差,准确率 0.2124,只有十类瞎猜的两倍。 交叉熵那一列的测试准确率全是 0.9823——激活函数只影响「快慢」,损失函数影响「能不能到」。

📝 本节和前面几节的关系

这一章从 1943 年的 M-P 模型讲到 1986 年的反向传播,最后落到三个实践问题:用什么损失、 用什么激活、怎么更新。 小结不该只重复结论,应该把这些东西放在一起跑一遍,看谁的影响大。 所以这一节的 Demo 是一个八组配置的对照实验。 它同时也是第 3 章的起点——下一章要换掉的不再是这几样,而是「层与层怎么连」。

一、这一章的零件清单

一个神经元 = 加权求和 + 非线性。 一层 = 一批这样的神经元。 一个网络 = 几层串起来,中间那几层叫隐层。 训练 = 前向算输出、算损失、反向算梯度、按学习率更新。

环节 这一章定下的东西 能不能在别的章换掉
前向 矩阵乘加非线性 第 3 章换连接方式,但形式不变
损失 分类用交叉熵、回归用平方误差 第 4、5 章换训练目标
反向 链式法则逐层递推 从未被换掉
更新 小批量梯度下降 可以换优化器,但骨架一样
超参数 学习率、批量、初始化 第 6 章加正则手段
注意第三行:反向传播从 1986 年到现在没有被换掉过。
换的只有「自动微分」这个实现方式,数学上一模一样。

二、八组配置一起跑

450 张七段数码管数字图,噪声标准差 0.30(故意糊一点),337 张训练、113 张测试。 网络 64 到 32 到 10,2410 个参数,小批量 32,学习率 0.1,200 轮。

隐层激活 损失 测试准确率 测试交叉熵
relu 交叉熵 0.9823 0.0757
relu 平方误差 0.6814 0.9914
tanh 交叉熵 0.9823 0.0841
tanh 平方误差 0.7434 1.0474
sigmoid 交叉熵 0.9823 0.1331
sigmoid 平方误差 0.2124 2.2089
看几件事。
第一,交叉熵那一列的准确率全是 0.9823,三种激活一个数都不差。
说明在这个任务上,激活函数只影响收敛快慢和最终概率质量(0.0757 到 0.1331),
不影响「能不能学会」。
第二,平方误差那一列全崩了,从 0.6814 掉到 0.2124。
同样的网络、同样的数据、同样的轮数,只换一行损失,准确率差三倍。
第三,sigmoid 配平方误差最惨(0.2124)。
两个「会让梯度变小」的因素叠在一起:sigmoid 饱和、平方误差的梯度里又乘了 a 乘 (1 减 a)。
叠起来就不是「慢」,是「几乎不动」。

三、训练集与测试集的差距

组合 训练准确率 测试准确率 差多少
relu 加交叉熵 1.0000 0.9823 0.0177
relu 加平方误差 0.7745 0.6814 0.0931
tanh 加交叉熵 1.0000 0.9823 0.0177
tanh 加平方误差 0.8249 0.7434 0.0816
sigmoid 加交叉熵 1.0000 0.9823 0.0177
sigmoid 加平方误差 0.3353 0.2124 0.1229
337 张训练图配 2410 个参数,交叉熵那一列的训练集全是 1.0000。
但测试集只有 0.9823——训练集上的完美说明不了什么。
平方误差那几行的差距更大(0.09 到 0.12),但那是「欠拟合」造成的,不是过拟合。
训练集都没学会(0.77、0.82),谈不上过拟合。
区分「没学好」和「学太死」要看训练准确率:低就是欠拟合,高而测试低才是过拟合。

四、这一章最该带走的三句话

第一,反向传播只算梯度,不负责优化。 认清这一点,才能在换优化器时不慌——反向传播一行都不用改。 第二,表达力和可训练性是两件事。 两个隐单元能表达异或,20 个种子只成功 9 次; 三层网络的参数够表达三条螺旋,但用错损失就学不出来。 「理论能做」和「实际做到」之间,隔着一整个优化问题。 第三,超参数不是装饰。 这一节八组配置里,最差和最好差 0.7699 的准确率,全部来自两行代码的选择。 调模型时的顺序应该是:先定结构,再扫损失和激活,然后调批量与学习率,最后才考虑加参数。

五、动手 Demo:换激活函数和损失函数,八种组合一起比

这段代码把第 2 章的零件全用上:64 到 32 到 10 的网络、2410 个参数、小批量 32、学习率 0.1、200 轮; 只换隐层激活和损失函数,跑六组(三乘二),再补一组训练集对照。 重点看三处。第二段交叉熵那一列全是 0.9823;平方误差那一列全崩; 以及最下面那行 0.3353(sigmoid 配平方误差)——它连训练集都没学会。

# -*- 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=60, size=8, seed=3, noise=0.12):
    """七段数码管画出来的 8x8 数字图,10 类。"""
    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
            img = img + rg.normal(0.0, noise, (size, size))
            X.append(img.ravel())
            Y.append(k)
    X = np.array(X)
    Y = np.array(Y)
    idx = rg.permutation(len(Y))
    return X[idx], Y[idx]


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, ytr, Ttr = X[:NTR], y[:NTR], T[:NTR]
Xte, yte, Tte = X[NTR:], y[NTR:], T[NTR:]
SIZES = [64, 32, 10]


def actf(z, act):
    if act == 'relu':
        return np.maximum(z, 0.0)
    if act == 'tanh':
        return np.tanh(z)
    return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))


def actd(a, z, act):
    if act == 'relu':
        return (z > 0.0).astype(float)
    if act == 'tanh':
        return 1.0 - a * a
    return a * (1.0 - a)


def init(seed=0, act='relu'):
    r = np.random.default_rng(seed)
    W, b = [], []
    for i in range(len(SIZES) - 1):
        sd = np.sqrt(2.0 / SIZES[i]) if act == 'relu' else np.sqrt(1.0 / SIZES[i])
        W.append(r.normal(0.0, sd, (SIZES[i], SIZES[i + 1])))
        b.append(np.zeros(SIZES[i + 1]))
    return {'W': W, 'b': b}


def fwd(p, X, act):
    Z, A = [], [X]
    h = X
    for i in range(len(p['W'])):
        z = h @ p['W'][i] + p['b'][i]
        Z.append(z)
        h = z if i == len(p['W']) - 1 else actf(z, act)
        A.append(h)
    return A, Z


def sm(S):
    S = S - S.max(axis=1, keepdims=True)
    E = np.exp(S)
    return E / E.sum(axis=1, keepdims=True)


def back(p, X, T, act, kind):
    A, Z = fwd(p, X, act)
    L = len(p['W'])
    P = sm(A[-1])
    if kind == 'mse':
        d = 2.0 * (P - T) * P * (1.0 - P) / T.size
    else:
        d = (P - T) / len(X)
    gW = [None] * L
    gb = [None] * L
    for i in range(L - 1, -1, -1):
        gW[i] = A[i].T @ d
        gb[i] = d.sum(axis=0)
        if i > 0:
            d = (d @ p['W'][i].T) * actd(A[i], Z[i - 1], act)
    return {'W': gW, 'b': gb}


def run(act, kind, lr=0.1, epochs=200, batch=32):
    p = init(0, act)
    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), batch):
            j = idx[s:s + batch]
            g = back(p, Xtr[j], Ttr[j], act, kind)
            for i in range(len(p['W'])):
                p['W'][i] -= lr * g['W'][i]
                p['b'][i] -= lr * g['b'][i]
    dt = time.perf_counter() - t0
    P = sm(fwd(p, Xte, act)[0][-1])
    a = float(np.mean(np.argmax(P, axis=1) == yte))
    ce = float(-np.mean(np.log(np.maximum((P * Tte).sum(axis=1), 1e-12))))
    Ptr = sm(fwd(p, Xtr, act)[0][-1])
    a_tr = float(np.mean(np.argmax(Ptr, axis=1) == ytr))
    return a, ce, dt, a_tr


print('[1] 把第 2 章讲的东西全用上一遍')
print('  数据:七段数码管画的 8x8 数字图,10 类,共 %d 张,%d 张训练、%d 张测试'
      % (len(y), NTR, len(y) - NTR))
print('  噪声标准差 0.30,故意让图像糊一点,好把各种组合拉开差距')
print('  网络:64 -> 32 -> 10,参数 %d 个'
      % (64 * 32 + 32 + 32 * 10 + 10))
print('  训练:小批量 32,学习率 0.1,200 轮;只换激活函数和损失函数')
print('')

print('[2] 8 种组合的结果(测试集)')
print('  隐层激活     损失       测试准确率   测试交叉熵   耗时')
rows = []
for act in ('relu', 'tanh', 'sigmoid'):
    for kind, tag in (('ce', '交叉熵'), ('mse', '平方误差')):
        a, ce, dt, a_tr = run(act, kind)
        rows.append((act, tag, a, ce, dt, a_tr))
        print('  ' + pad(act, 13) + pad(tag, 11) + pad('%.4f' % a, 13)
              + pad('%.4f' % ce, 13) + '%.3f 秒' % dt)
print('')

print('[3] 三个信号')
best = max(rows, key=lambda r: r[2])
worst = min(rows, key=lambda r: r[2])
print('  最好的一组:%s + %s,测试准确率 %.4f' % (best[0], best[1], best[2]))
print('  最差的一组:%s + %s,测试准确率 %.4f' % (worst[0], worst[1], worst[2]))
print('  差距 %.4f——同一份数据、同一个网络结构,只换两行代码就差这么多'
      % (best[2] - worst[2]))
print('')

print('[4] 这个差距在提醒什么')
print('  第 2 章讲的每一样东西(激活函数、损失函数、小批量、学习率)都不是装饰')
print('  它们不改变网络的表达能力,只改变「能不能训出来、训得多快」')
print('  所以调模型时的顺序是:先把结构定下来,再扫这几样,最后才考虑加参数')
print('')

print('[5] 训练集和测试集差多少')
print('  组合             训练准确率   测试准确率   差多少')
for r in rows:
    print('  ' + pad('%s + %s' % (r[0], r[1]), 21) + pad('%.4f' % r[5], 13)
          + pad('%.4f' % r[2], 13) + '%.4f' % (r[5] - r[2]))
print('')
print('  %d 张训练图配 2410 个参数,交叉熵那一列的训练集全是 1.0000' % NTR)
print('  但测试集只有 0.9823——训练集上的完美说明不了什么')
print('  第 6 章整章都在处理这件事')

实跑输出:

--------------------------------------------------------------------
小结:换激活函数和损失函数,八种组合一起比
--------------------------------------------------------------------
[1] 把第 2 章讲的东西全用上一遍
  数据:七段数码管画的 8x8 数字图,10 类,共 450 张,337 张训练、113 张测试
  噪声标准差 0.30,故意让图像糊一点,好把各种组合拉开差距
  网络:64 -> 32 -> 10,参数 2410 个
  训练:小批量 32,学习率 0.1,200 轮;只换激活函数和损失函数

[2] 8 种组合的结果(测试集)
  隐层激活     损失       测试准确率   测试交叉熵   耗时
  relu         交叉熵     0.9823       0.0757       0.238 秒
  relu         平方误差   0.6814       0.9914       0.250 秒
  tanh         交叉熵     0.9823       0.0841       0.255 秒
  tanh         平方误差   0.7434       1.0474       0.270 秒
  sigmoid      交叉熵     0.9823       0.1331       0.281 秒
  sigmoid      平方误差   0.2124       2.2089       0.289 秒

[3] 三个信号
  最好的一组:relu + 交叉熵,测试准确率 0.9823
  最差的一组:sigmoid + 平方误差,测试准确率 0.2124
  差距 0.7699——同一份数据、同一个网络结构,只换两行代码就差这么多

[4] 这个差距在提醒什么
  第 2 章讲的每一样东西(激活函数、损失函数、小批量、学习率)都不是装饰
  它们不改变网络的表达能力,只改变「能不能训出来、训得多快」
  所以调模型时的顺序是:先把结构定下来,再扫这几样,最后才考虑加参数

[5] 训练集和测试集差多少
  组合             训练准确率   测试准确率   差多少
  relu + 交叉熵        1.0000       0.9823       0.0177
  relu + 平方误差      0.7745       0.6814       0.0931
  tanh + 交叉熵        1.0000       0.9823       0.0177
  tanh + 平方误差      0.8249       0.7434       0.0816
  sigmoid + 交叉熵     1.0000       0.9823       0.0177
  sigmoid + 平方误差   0.3353       0.2124       0.1229

  337 张训练图配 2410 个参数,交叉熵那一列的训练集全是 1.0000
  但测试集只有 0.9823——训练集上的完美说明不了什么
  第 6 章整章都在处理这件事

这张表最反直觉的是「交叉熵那一列三个 0.9823 一模一样」。 三种完全不同的激活函数,准确率一个不差。 这说明在「能学会」这件事上,激活函数的差别被损失函数盖过去了。 差别体现在测试交叉熵上:0.0757、0.0841、0.1331——预测概率的质量差一倍。 平方误差全崩这件事,要结合上一章的推导看。 softmax 输出的概率被压到接近 0 或 1 时,平方误差的梯度里有 a 乘 (1 减 a),趋近于零。 十类问题里,正确类别的概率一开始只有 0.1 左右, 按说不该饱和——但训练几十轮之后一部分样本先学会了,它们就饱和了,然后不再贡献梯度。 sigmoid 加平方误差那一行是 0.2124,训练集也只有 0.3353。 这是「两个减分项叠在一起」的典型:sigmoid 在隐层已经衰减一次,平方误差在输出层又衰减一次。 200 轮之内它基本没动。 第五段那个「训练集与测试集」的对照值得单独看。 交叉熵三行的差都是 0.0177,一模一样——因为三行训练出来的模型几乎相同。 平方误差三行的差是 0.08 到 0.12,不是过拟合,是欠拟合:训练集都没到 0.85。 代码里 run 每次都用 init(0, act) 重新初始化,保证几组之间起点相同。 如果共用一个初始化对象,后面的组会从前面训练完的权重开始,结果就没法比了。 这种「对照实验要控制变量」的纪律,比代码本身重要。

30 秒实验

  1. 把 SIZES 从 [64, 32, 10] 改成 [64, 10](去掉隐层),重跑。 所有组合的准确率都会掉到 0.9 以下。 8 乘 8 的数字图直接用线性分类也能到八九成,但到不了 0.98——差的就是那一层非线性。
  2. 把 noise 从 0.30 改成 0.10,重跑。平方误差那几行会追上来一些,因为任务变简单了。 「损失函数选错」的代价和任务难度有关:任务越难,代价越大。
  3. 把 lr 从 0.1 改成 1.0,重跑。sigmoid 那两行会明显变差,relu 那两行还行。 sigmoid 的梯度本来就小,需要更大的学习率;但它又容易饱和,学习率大了会震荡。 这就是 sigmoid 难调的原因。

常见误区

  1. 「第 2 章讲的是最基础的东西,实际用不上」。 今天所有框架里,前向是矩阵乘、反向是链式法则、更新是梯度下降,一样没变。 框架省掉的是手写这些代码的工夫,不是这些概念本身。
  2. 「损失函数和激活函数可以随便配」。 实测 sigmoid 配平方误差只有 0.2124,relu 配交叉熵 0.9823。 同一份数据、同一个网络结构,差 0.7699。
  3. 「训练准确率 1.0000 就说明模型很好」。 交叉熵三行的训练集全是 1.0000,测试集 0.9823。 训练集上的完美只说明「参数够多、能背下来」,不说明学到了规律。
  4. 「超参数调不好是因为没找到最优值」。 学习率 0.05 到 1.0 一整段都能到 1.0000,说明它是个区间不是点。 真正该花时间的是结构、数据和损失函数,不是把学习率精确到小数点后三位。
自测三问(点开对答案) 1. 为什么交叉熵那一列三种激活的准确率完全相同? —— 因为在这个任务上,三种激活都能把训练做到底,差别只在快慢和最终概率质量。 准确率只关心「最大那个是不是对的」,所以看不出差别; 测试交叉熵(0.0757 到 0.1331)才反映出差别。 2. sigmoid 配平方误差为什么最差? —— 两个让梯度变小的因素叠加:sigmoid 在饱和时导数趋近于零, 平方误差的梯度里又乘了 a 乘 (1 减 a)。 200 轮之后测试准确率只有 0.2124,训练集也只有 0.3353——基本没学动。 3. 训练准确率 1.0000、测试 0.9823,这是过拟合吗? —— 轻微的。 337 张图配 2410 个参数,确实有背下来的空间,但差距只有 0.0177,而且测试集本身也就 113 张, 抽样误差就有几个百分点。真正的过拟合要看「训练集很高、测试集明显低」且差距随训练持续扩大。

第 2 章的骨架立起来了:全连接、逐层非线性、反向传播、小批量更新。 但它有个明显的浪费:把一张图摊平成一个长向量,等于扔掉了「哪些像素挨着」这件事。 下一章换掉的就是这个连接方式——卷积层。

相关笔记