同样是「真值是 1、输出是 0.98」,平方误差给的梯度是 0.000392,交叉熵给的是 0.020000。 差 51 倍,而且输出越接近 1,差距越大:0.9999 时差 10001 倍。 整个网络跑一遍:交叉熵 300 轮后准确率 1.0000、损失 0.0065;平方误差 0.9844、0.0916。 根源在 sigmoid 的导数:它在饱和时趋近于零,而平方误差的梯度里正好乘了这一项。
上一节把梯度算对了,这一节讨论「算的是哪个函数的梯度」。 换损失函数、换激活函数,反向传播的代码结构一点不变,变的是那两处公式。 这两处选择看着像风格问题,实际上是决定性的。 选错的时候,程序照样跑,损失照样降,只是降到一个不怎么样就停了。 这一节先把两种损失的梯度差算给你看,再换整个网络验证,最后把四种激活函数摆在一起。
一、两种损失的梯度差在哪
输出层一个 sigmoid,真值 t 是 1。 两种损失的导数:
| 损失 | 公式 | 对 z 的导数 | |---|---|---|---| | 平方误差 | (a 减 t) 的平方 除以 2 | (a 减 t) 乘 a 乘 (1 减 a) | | 交叉熵 | 负 t 乘 ln(a) 减 (1 减 t) 乘 ln(1 减 a) | a 减 t | 差别就在多出来的那个 a 乘 (1 减 a) 上——它正好是 sigmoid 的导数。 为什么交叉熵能把它约掉?因为求导时 ln(a) 对 z 求导是 (1 除以 a) 乘 a 的导数, 那个 a 的导数里带着 a 乘 (1 减 a),正好和分母的 a 约掉一部分。 这不是巧合,是从最大似然推出来的结果(下一节会推)。 实测这个差别有多大:
| 输出 a | 平方误差的梯度 | 交叉熵的梯度 | 相差 |
|---|---|---|---|
| 0.5000 | -0.12500000 | -0.50000000 | 4.0 倍 |
| 0.9000 | -0.00900000 | -0.10000000 | 11.1 倍 |
| 0.9800 | -0.00039200 | -0.02000000 | 51.0 倍 |
| 0.9990 | -0.00000100 | -0.00100000 | 1001.0 倍 |
| 0.9999 | -0.00000001 | -0.00010000 | 10001.0 倍 |
| 注意最后一列:输出越接近 1,差距越大。 | |||
| 因为 a 乘 (1 减 a) 在两端趋近于零,平方误差的梯度就被压到几乎没有了。 |
二、饱和到底意味着什么
sigmoid 在 z 很大或很小时,输出贴着 1 或 0 不动。 这时候无论怎么改 z,输出都几乎不变——所以导数趋近于零。 问题在于「a 接近 1」这件事的两面性: 如果真值就是 1,那是好事,说明学对了; 如果真值是 0 而输出是 0.999,那是最糟的情况——错得离谱,却几乎没有梯度可用。 平方误差恰好在这种时候把梯度压得更小。 交叉熵不会:它的梯度始终等于 a 减 t,错得越离谱,梯度越大。 这就是「交叉熵对错误更敏感」的准确含义。
三、换整个网络验证
三条螺旋 450 个点,两层网络(2 到 8 到 3),同样的初始化、同样的 300 轮:
| 损失函数 | 300 轮后的准确率 | 300 轮后的交叉熵 |
|---|---|---|
| 交叉熵 | 1.0000 | 0.006500 |
| 平方误差 | 0.9844 | 0.091568 |
| 准确率只差 0.0156,看上去还行;但交叉熵差了 14 倍。 | ||
| 说明平方误差训出来的模型,虽然分类对了,但输出的概率很不像话——它对正确答案的置信度不够。 | ||
| 这在只关心「猜对没猜对」时不重要,一旦要拿概率做后续判断(比如阈值、 | ||
| 比如多模型融合)就很要命。 |
四、四种激活函数摆在一起
取三个点看它们的值和导数:
| 函数 | z 等于负 3 | z 等于 0 | z 等于 3 | z 等于 3 处的导数 |
|---|---|---|---|---|
| 阶跃 | 0.0000 | 0.0000 | 1.0000 | 0.0000 |
| sigmoid | 0.0474 | 0.5000 | 0.9526 | 0.0452 |
| tanh | -0.9951 | 0.0000 | 0.9951 | 0.0099 |
| relu | 0.0000 | 0.0000 | 3.0000 | 1.0000 |
| 阶跃的导数处处是零,所以它没法用梯度训练。 | ||||
| 这就是感知器为什么要单独设计一套更新规则——不是因为不想用梯度,是根本没有梯度。 | ||||
| sigmoid 和 tanh 在 z 等于 3 处的导数只剩 0.0452 和 0.0099。 | ||||
| 层数一多,这个小数一层层乘下去,梯度就消失了。 | ||||
| 这也是 sigmoid 在深层网络里几乎绝迹的原因。 | ||||
| ReLU 的导数是 1,不衰减。 | ||||
| 它在 2011 年之后迅速流行,主要就是这个原因——不是因为形状好看。 | ||||
| 代价是它有一半区间导数为零,单元一旦「死掉」就再也不更新。 |
五、怎么选
隐层:默认 ReLU。 它不衰减、算得快(就是取最大值),绝大多数情况下够用。 如果发现大量单元输出恒为零,换成 Leaky ReLU 或 tanh。 输出层:跟着任务走。 回归用不激活(直接输出实数),二分类用 sigmoid,多分类用 softmax。 损失跟着一起定:分类配交叉熵,回归配平方误差。 这是一条很少需要商量的规则。 真正需要商量的是「平方误差配 sigmoid」这种组合——它存在,但基本都该换成交叉熵。
六、动手 Demo:饱和时谁还剩下梯度
这段代码先在一个孤立的 sigmoid 上算两种损失的梯度,看输出越接近 1 时差多少; 再换整个网络,只改损失函数跑 300 轮;最后把四种激活函数的值和导数排成表。 重点看三处。第一段的 4.0 倍一路涨到 10001 倍;第二段 1.0000 对 0.9844、0.0065 对 0.0916; 第四段 relu 在 z 等于 3 处导数还是 1.0000。
# -*- coding: utf-8 -*-
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 sig(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))
print('[1] 输出层一个 sigmoid,真值是 1,看两种损失的梯度差多少')
print(' 平方误差 E = (a - t) 的平方 / 2 dE/dz = (a - t) * a * (1 - a)')
print(' 交叉熵 E = -t*ln(a) - (1-t)*ln(1-a) dE/dz = (a - t)')
print(' 差就差在那个 a*(1-a) 上——它是 sigmoid 的导数')
print('')
print(' 输出 a 平方误差的梯度 交叉熵的梯度 差多少倍')
for a in (0.50, 0.90, 0.98, 0.999, 0.9999):
g_mse = (a - 1.0) * a * (1.0 - a)
g_ce = (a - 1.0)
print(' ' + pad('%.4f' % a, 11) + pad('%.8f' % g_mse, 17)
+ pad('%.8f' % g_ce, 16) + '%.1f 倍' % (g_ce / g_mse))
print('')
print(' a 越接近 1(越饱和),平方误差的梯度越小,小于 0.0001 之后就等于停了')
print(' 交叉熵的梯度始终等于 (a - t),饱和时它反而最大')
print('')
print('[2] 换整个网络看:同一份数据、同一个初始化,只换损失函数')
def spiral(n=150, C=3, seed=1, noise=0.12):
rg = np.random.default_rng(seed)
X, Y = [], []
for c in range(C):
t = np.linspace(0.4, 4.2, n)
ang = t + c * 2.0 * np.pi / C
X.append(np.column_stack([t * np.cos(ang), t * np.sin(ang)])
+ rg.normal(0.0, noise, (n, 2)))
Y.append(np.full(n, c))
return np.vstack(X), np.concatenate(Y)
X, y = spiral(150, 3, seed=1)
T = np.zeros((len(y), 3))
T[np.arange(len(y)), y] = 1.0
def init(sizes, seed=1):
r = np.random.default_rng(seed)
W, b = [], []
for i in range(len(sizes) - 1):
W.append(r.normal(0.0, np.sqrt(2.0 / sizes[i]), (sizes[i], sizes[i + 1])))
b.append(np.zeros(sizes[i + 1]))
return {'W': W, 'b': b}
def fwd(p, X):
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 np.maximum(z, 0.0)
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, kind):
A, Z = fwd(p, X)
L = len(p['W'])
if kind == 'mse':
P = sm(A[-1])
d = 2.0 * (P - T) * P * (1.0 - P) / T.size
else:
d = (sm(A[-1]) - 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) * (Z[i - 1] > 0.0)
return {'W': gW, 'b': gb}
def run(kind, lr=0.2, epochs=300):
p = init([2, 8, 3], seed=1)
rg = np.random.default_rng(0)
for _ in range(epochs):
idx = rg.permutation(len(X))
for s in range(0, len(X), 32):
j = idx[s:s + 32]
g = back(p, X[j], T[j], kind)
for i in range(len(p['W'])):
p['W'][i] -= lr * g['W'][i]
p['b'][i] -= lr * g['b'][i]
A, _ = fwd(p, X)
P = sm(A[-1])
acc = float(np.mean(np.argmax(P, axis=1) == y))
ce = float(-np.mean(np.log(np.maximum((P * T).sum(axis=1), 1e-12))))
return acc, ce
print(' 损失函数 300 轮后的准确率 300 轮后的交叉熵')
for kind, tag in (('ce', '交叉熵'), ('mse', '平方误差')):
acc, ce = run(kind)
print(' ' + pad(tag, 11) + pad('%.4f' % acc, 20) + '%.6f' % ce)
print('')
print('[3] 为什么平方误差慢:它的梯度里多乘了一个 a*(1-a)')
print(' relu 隐层不会饱和,但输出层的 softmax 之后接 sigmoid 就会')
print(' 一旦某个输出把概率压到 0.99 以上,平方误差给它的梯度就趋近于零')
print(' 于是「已经错的样本」反而学不动,这就是交叉熵存在的理由')
print('')
print('[4] 顺带看一眼激活函数本身')
print(' 函数 z = -3 z = 0 z = 3 导数在 z=3 的值')
for nm, f, df in (('阶跃', lambda z: 0.0 if z <= 0 else 1.0, lambda z: 0.0),
('sigmoid', lambda z: float(sig(z)), lambda z: float(sig(z) * (1 - sig(z)))),
('tanh', lambda z: float(np.tanh(z)), lambda z: float(1 - np.tanh(z) ** 2)),
('relu', lambda z: max(0.0, z), lambda z: 0.0 if z <= 0 else 1.0)):
print(' ' + pad(nm, 11) + pad('%.4f' % f(-3), 11) + pad('%.4f' % f(0), 11)
+ pad('%.4f' % f(3), 11) + '%.4f' % df(3))
print('')
print(' 阶跃的导数是 0,所以它没法用梯度训练(感知器只能靠「分错了就推」)')
print(' sigmoid 在 z=3 处导数只剩 %.4f,tanh 是 %.4f,relu 还是 1'
% (float(sig(3) * (1 - sig(3))), float(1 - np.tanh(3) ** 2)))
实跑输出:
--------------------------------------------------------------------
误差函数和激活函数:饱和时谁还剩下梯度
--------------------------------------------------------------------
[1] 输出层一个 sigmoid,真值是 1,看两种损失的梯度差多少
平方误差 E = (a - t) 的平方 / 2 dE/dz = (a - t) * a * (1 - a)
交叉熵 E = -t*ln(a) - (1-t)*ln(1-a) dE/dz = (a - t)
差就差在那个 a*(1-a) 上——它是 sigmoid 的导数
输出 a 平方误差的梯度 交叉熵的梯度 差多少倍
0.5000 -0.12500000 -0.50000000 4.0 倍
0.9000 -0.00900000 -0.10000000 11.1 倍
0.9800 -0.00039200 -0.02000000 51.0 倍
0.9990 -0.00000100 -0.00100000 1001.0 倍
0.9999 -0.00000001 -0.00010000 10001.0 倍
a 越接近 1(越饱和),平方误差的梯度越小,小于 0.0001 之后就等于停了
交叉熵的梯度始终等于 (a - t),饱和时它反而最大
[2] 换整个网络看:同一份数据、同一个初始化,只换损失函数
损失函数 300 轮后的准确率 300 轮后的交叉熵
交叉熵 1.0000 0.006500
平方误差 0.9844 0.091568
[3] 为什么平方误差慢:它的梯度里多乘了一个 a*(1-a)
relu 隐层不会饱和,但输出层的 softmax 之后接 sigmoid 就会
一旦某个输出把概率压到 0.99 以上,平方误差给它的梯度就趋近于零
于是「已经错的样本」反而学不动,这就是交叉熵存在的理由
[4] 顺带看一眼激活函数本身
函数 z = -3 z = 0 z = 3 导数在 z=3 的值
阶跃 0.0000 0.0000 1.0000 0.0000
sigmoid 0.0474 0.5000 0.9526 0.0452
tanh -0.9951 0.0000 0.9951 0.0099
relu 0.0000 0.0000 3.0000 1.0000
阶跃的导数是 0,所以它没法用梯度训练(感知器只能靠「分错了就推」)
sigmoid 在 z=3 处导数只剩 0.0452,tanh 是 0.0099,relu 还是 1
第一段那五个输出值是挑出来的,不是算出来的——0.5、0.9、0.98、0.999、
0.9999 分别代表「刚开始」「快收敛」「很自信」「几乎确定」「确定到极致」五种状态。
表格最后一列是用交叉熵的梯度除以平方误差的梯度算出来的,不是手填的。
所以它显示的倍数关系是真实的函数关系:1 除以 (a 乘 (1 减 a))。
第二段的准确率几乎没差,这件事容易被误读成「两种损失差不多」。
要看交叉熵那一列:0.0065 对 0.0916,差 14 倍。
准确率只关心「最大那个是不是对的」,交叉熵还关心「对的那个有多大」。
第四段最该记住的是阶跃那一行:导数全是 0.0000。
它不是「导数很小」,是「根本没有导数可用」。
这解释了一个常见困惑:为什么感知器不能直接套用梯度下降。
代码里平方误差的梯度写的是 2 * (P - T) * P * (1 - P),
那个 P * (1 - P) 就是从 sigmoid 来的。
注意后面那个 P 是 softmax 的输出,不是真的 sigmoid——这里代码做了简化,
把 softmax 的每个分量当作独立的概率处理。
严格的推导要用 softmax 的雅可比矩阵,结论方向一样,但系数不同。
30 秒实验
- 把第一段的
a加到 0.99999,重跑。平方误差的梯度会小到 1e-10 以下。 这个量级的更新,对权重基本等于没动。 - 把第二段的
lr从 0.2 改成 0.05,重跑。平方误差那一行会更差,可能掉到 0.95 以下。 梯度本来就小,步长再小就更走不动。 - 把
run里的epochs从 300 改成 2000,重跑。平方误差能追上来一些,但通常还是不如交叉熵。 「慢」可以用轮数补,「饱和时没有梯度」补不了。
常见误区
- 「损失函数只是个评分标准,选哪个都行」。 实测同一个网络,交叉熵 1.0000、平方误差 0.9844,交叉熵那一列还差 14 倍。 损失函数的梯度形状决定了训练能不能走下去。
- 「交叉熵比平方误差准」。 准确率上只差 0.0156,真正的差别在「输出概率的质量」和「训练速度」。 说「交叉熵训得快、概率更可信」才对。
- 「sigmoid 是经典激活函数,应该优先用」。 它在 z 等于 3 处导数只剩 0.0452,深层网络里一层层乘下去就是梯度消失。 今天隐层默认是 ReLU,sigmoid 基本只留在输出层。
- 「ReLU 就是取最大值,太简单了不可能好用」。 它的价值恰恰在简单:导数不是 1 就是 0,不会衰减,算起来也快。 「简单」在这里是优点,不是缺点。
自测三问(点开对答案)
1. 同样是输出 0.98、真值 1,为什么平方误差的梯度比交叉熵小 51 倍? —— 因为平方误差的梯度里多乘了一个 a 乘 (1 减 a),那正好是 sigmoid 的导数。 输出越接近 1,这个因子越小,梯度就被压得越低。 交叉熵的梯度是 a 减 t,没有这个因子,饱和时它反而最大。 2. 阶跃函数为什么不能用来做梯度训练? —— 它的导数处处为零(除了在 0 点不可导)。梯度是零,更新量就是零,参数不会动。 这也解释了感知器为什么要单独设计一套「分错了就推」的规则——不是不想用梯度, 是根本没有梯度可用。 3. ReLU 为什么在深层网络里取代了 sigmoid? —— 因为 sigmoid 的导数最大只有 0.25,层数一多就一层层乘到接近零,梯度消失。 ReLU 在正区间的导数是 1,不衰减。代价是负区间导数为零,单元可能「死掉」再也不更新。交叉熵是从哪来的?为什么它和 sigmoid 配起来那么顺? 答案不在「设计得巧」,而在「它本来就是从概率推出来的」。 下一节从似然函数一路推到交叉熵。
- ☐ (本节完,下一节 2.7 似然函数)