把第 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 秒实验
- 把
SIZES从[64, 32, 10]改成[64, 10](去掉隐层),重跑。 所有组合的准确率都会掉到 0.9 以下。 8 乘 8 的数字图直接用线性分类也能到八九成,但到不了 0.98——差的就是那一层非线性。 - 把
noise从 0.30 改成 0.10,重跑。平方误差那几行会追上来一些,因为任务变简单了。 「损失函数选错」的代价和任务难度有关:任务越难,代价越大。 - 把
lr从 0.1 改成 1.0,重跑。sigmoid 那两行会明显变差,relu 那两行还行。 sigmoid 的梯度本来就小,需要更大的学习率;但它又容易饱和,学习率大了会震荡。 这就是 sigmoid 难调的原因。
常见误区
- 「第 2 章讲的是最基础的东西,实际用不上」。 今天所有框架里,前向是矩阵乘、反向是链式法则、更新是梯度下降,一样没变。 框架省掉的是手写这些代码的工夫,不是这些概念本身。
- 「损失函数和激活函数可以随便配」。 实测 sigmoid 配平方误差只有 0.2124,relu 配交叉熵 0.9823。 同一份数据、同一个网络结构,差 0.7699。
- 「训练准确率 1.0000 就说明模型很好」。 交叉熵三行的训练集全是 1.0000,测试集 0.9823。 训练集上的完美只说明「参数够多、能背下来」,不说明学到了规律。
- 「超参数调不好是因为没找到最优值」。 学习率 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 章的骨架立起来了:全连接、逐层非线性、反向传播、小批量更新。 但它有个明显的浪费:把一张图摊平成一个长向量,等于扔掉了「哪些像素挨着」这件事。 下一章换掉的就是这个连接方式——卷积层。
- ☐ (本章完,下一节 3.1 卷积神经网络的结构)