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

输出层要回答的只有一个问题:这属于哪一类。 回归用 1 个数不激活,二分类用 sigmoid,多分类用 softmax,多标签还是 sigmoid。 softmax 里那三行代码是数值需要,不是数学需要:logits 是 1000、1001、1002 时, 直接算 exp 得到三个 nan,先减最大值才得到 0.09、0.2447、0.6652。 多标签时三个概率加起来能到 2.39——这不是错,因为一张图可以同时是猫和狗。

📝 本节和前面几节的关系

前四节把「怎么从像素里抽特征」讲完了,这一节讲「怎么把特征变成答案」。 输出层是唯一一处「必须跟着任务走」的地方。 隐层可以无脑用 ReLU,输出层不行——选错就是把模型用错了。 这一节还有一个很实用的知识点:softmax 的数值实现。 它只有三行代码,但少写一行就会在你的模型跑到第几万轮时突然全变成 nan。

一、四种任务,四种输出层

任务 输出个数 最后一层 损失
回归 1 不激活 平方误差
二分类 1 sigmoid 二元交叉熵
多分类 类别数 softmax 交叉熵
多标签 类别数 sigmoid 各类二元交叉熵之和
这张表是「配对的」,不能拆开随便组合。
softmax 配交叉熵、sigmoid 配二元交叉熵,这两对里的导数都能约掉,梯度干净。
乱配的话梯度里会多一项激活函数的导数,训练会慢很多——第 2.6 节量过,差 51 倍。
判断用 softmax 还是 sigmoid,只问一句:这些类互斥吗?
互斥(一张图只能是 0 到 9 中的一个数字)就用 softmax;
不互斥(一张图里可以同时有猫和狗)就用 sigmoid。

二、softmax 在做什么

它把一组实数变成一组加起来等于 1 的正数。 做法是:先各自取指数,再除以总和。

softmax(z) = exp(z) / sum(exp(z))

取指数的目的是「放大差距」:大的更正、小的被压下去。 除以总和是为了「归一化」:结果可以被当成概率。 实测 logits 是 2.0、1.0、0.1 时,概率是 0.659、0.2424、0.0986,和是 1.000000。 注意它保持大小顺序,但不是「各自独立压缩」——改一个 logit,三类的概率都会变。

三、那三行代码为什么必须有

logits 是 1000、1001、1002 的时候:

写法 结果
直接 exp 再归一化 nan, nan, nan
先减最大值再 exp 0.09, 0.2447, 0.6652
exp(1000) 是 10 的 434 次方,早超出双精度浮点的上限(约 1.8 乘 10 的 308 次方)。
溢出成无穷大,无穷大除以无穷大就是 nan。
减去最大值之后,指数最大是 0,最小是负 2,绝不溢出。
而「分子分母同时除以同一个数」不改变比值,所以数学上完全等价。
这就是所有框架里 softmax 都写成那三行的原因。
它不是为了省事,是为了数值稳定——而这个问题只有在你训练到几十万轮、
logit 涨到几百的时候才会暴露。

四、多标签为什么不能用 softmax

三类「各自是不是」,logits 是 2.2、1.4、0.8: sigmoid 之后是 0.9002、0.8022、0.69,加起来 2.3924。 超过 1 是对的。 一张图里同时有猫和狗,两个概率都该高;三个类都命中,加起来接近 3 也合理。 softmax 会强行把它们压成「三选一」,等于告诉模型「这三个里只能有一个对」。 这个假设在互斥分类里是对的,在多标签里是错的。 所以「输出层怎么选」这件事,本质上是在说「我对标签的假设是什么」。

五、交叉熵只看正确那一类

正确类的概率 损失
0.9 0.1054
0.6 0.5108
0.3 1.2040
0.1 2.3026
它不看其它类的概率怎么分配。
十类里正确类概率 0.9、其余摊 0.1,和正确类 0.9、其余全给第二名,损失是一样的。
这是交叉熵的一个「特点」也是一个「缺点」:它不惩罚「把错误的类分得很细」。
所以有些任务会额外加「标签平滑」,把目标从 1 改成 0.9,逼模型别太自信。

六、动手 Demo:softmax 的三行代码是数值需要,不是数学需要

这段代码先看 softmax 把一组 logits 变成概率,再演示 logits 很大时直接算会溢出; 然后比较多标签场景下 sigmoid 和 softmax 的差别,最后列出交叉熵在不同概率下的值。 重点看两处。第三段 nan 对 0.09、0.2447、0.6652;第四段三个 sigmoid 概率加起来 2.3924。

# -*- coding: utf-8 -*-
import numpy as np


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


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


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -60.0, 60.0)))


print('[1] 输出层要回答的问题只有一个:这属于哪一类')
print('  三种任务,三种输出层:')
print('  回归      1 个数,不激活,损失用平方误差')
print('  二分类    1 个数过 sigmoid,损失用二元交叉熵')
print('  多分类    类别数个数过 softmax,损失用交叉熵')
print('  多标签    类别数个数过 sigmoid(每类独立判断是不是)')
print('')

print('[2] softmax 就一句话:把一组实数变成加起来等于 1 的概率')
S = np.array([2.0, 1.0, 0.1])
P = softmax_stable(S)
print('  logits = %s' % np.round(S, 2).tolist())
print('  概率   = %s,和 = %.6f' % (np.round(P, 4).tolist(), P.sum()))
print('  它保持大小顺序:logits 最大的那一类概率也最大')
print('  但它不是「各自独立压缩」,改一个 logit 会影响所有类的概率')
print('')

print('[3] 直接算 exp 会溢出,所以实现里要先减最大值')
big = np.array([1000.0, 1001.0, 1002.0])
with np.errstate(over='ignore', invalid='ignore'):
    naive = softmax_naive(big)
stable = softmax_stable(big)
print('  logits = %s' % big.tolist())
print('  直接 exp 的结果:%s' % np.round(naive, 4).tolist())
print('  先减最大值再 exp:%s' % np.round(stable, 4).tolist())
print('')
print('  exp(1000) 早就超出双精度浮点的范围了,除以无穷大得到的是 nan。')
print('  减去最大值之后指数最大是 0,绝不会溢出;而减掉同一个常数不改变比值。')
print('  这就是所有框架里 softmax 都写成那三行的原因——不是数学需要,是数值需要。')
print('')

print('[4] 多标签时不能用 softmax')
z = np.array([2.2, 1.4, 0.8])
p_multi = sigmoid(z)
print('  三类「各自是不是」:sigmoid 之后是 %s,和 = %.4f'
      % (np.round(p_multi, 4).tolist(), p_multi.sum()))
print('  三个都不小,加起来超过 1——这在一张图里同时有猫和狗时是合理的。')
print('  用 softmax 就会强行把它们压成「三选一」,那是错的。')
print('  所以选输出层要先问一句:这些类互斥吗?')
print('')

print('[5] 交叉熵只看正确那一类的概率')
for p in (0.9, 0.6, 0.3, 0.1):
    print('  正确类概率 %.1f -> 损失 %.4f' % (p, -np.log(p)))
print('')
print('  概率 0.9 时损失 0.105,0.6 时 0.511,0.1 时 2.303。')
print('  它对「很自信但错了」罚得极重,这就是上一章讲过的那个性质。')
print('  注意它不看其它类的概率怎么分配——只要正确那一类够高,损失就小。')

实跑输出:

--------------------------------------------------------------------
输出层:softmax 的三行代码是数值需要,不是数学需要
--------------------------------------------------------------------
[1] 输出层要回答的问题只有一个:这属于哪一类
  三种任务,三种输出层:
  回归      1 个数,不激活,损失用平方误差
  二分类    1 个数过 sigmoid,损失用二元交叉熵
  多分类    类别数个数过 softmax,损失用交叉熵
  多标签    类别数个数过 sigmoid(每类独立判断是不是)

[2] softmax 就一句话:把一组实数变成加起来等于 1 的概率
  logits = [2.0, 1.0, 0.1]
  概率   = [0.659, 0.2424, 0.0986],和 = 1.000000
  它保持大小顺序:logits 最大的那一类概率也最大
  但它不是「各自独立压缩」,改一个 logit 会影响所有类的概率

[3] 直接算 exp 会溢出,所以实现里要先减最大值
  logits = [1000.0, 1001.0, 1002.0]
  直接 exp 的结果:[nan, nan, nan]
  先减最大值再 exp:[0.09, 0.2447, 0.6652]

  exp(1000) 早就超出双精度浮点的范围了,除以无穷大得到的是 nan。
  减去最大值之后指数最大是 0,绝不会溢出;而减掉同一个常数不改变比值。
  这就是所有框架里 softmax 都写成那三行的原因——不是数学需要,是数值需要。

[4] 多标签时不能用 softmax
  三类「各自是不是」:sigmoid 之后是 [0.9002, 0.8022, 0.69],和 = 2.3924
  三个都不小,加起来超过 1——这在一张图里同时有猫和狗时是合理的。
  用 softmax 就会强行把它们压成「三选一」,那是错的。
  所以选输出层要先问一句:这些类互斥吗?

[5] 交叉熵只看正确那一类的概率
  正确类概率 0.9 -> 损失 0.1054
  正确类概率 0.6 -> 损失 0.5108
  正确类概率 0.3 -> 损失 1.2040
  正确类概率 0.1 -> 损失 2.3026

  概率 0.9 时损失 0.105,0.6 时 0.511,0.1 时 2.303。
  它对「很自信但错了」罚得极重,这就是上一章讲过的那个性质。
  注意它不看其它类的概率怎么分配——只要正确那一类够高,损失就小。

第二段的 1.000000 是这一节的第一个要点:softmax 的输出一定归一。 它保持大小顺序,所以 argmax 不变——分类时只看谁最大,其实不需要真的归一化。 归一化的价值在于「能被当成概率」,才能算交叉熵、才能做阈值判断。 第三段那三个 nan 是全节最有实用价值的地方。 它说明「数学上等价的写法」在浮点世界里不等价。 你写的 softmax 如果没有减最大值,小模型跑几百轮看不出问题,大模型跑几天才炸。 第四段 2.3924 这个数容易被当成 bug。 它不是——它是「多标签」和「多分类」的区别在数值上的表现。 判断标准是「这些类互斥吗」,不是「加起来是不是 1」。 第五段那张表说明交叉熵只关心正确类。 所以「准确率」和「交叉熵」是两件事:准确率只看 argmax 对不对,交叉熵还看对了多少。 代码里 softmax_naive 是故意留着的反面例子,softmax_stable 才是该用的写法。 np.errstate 那两行只是为了让警告不刷屏——在真实代码里你会想知道它溢出了。

30 秒实验

  1. 把 big 改成 100、101、102,重跑。直接算也能得到正确结果,因为 exp(100) 还在浮点范围内。 所以「减最大值」这一步在 logit 小的时候看不出差别,这正是它容易被漏掉的原因。
  2. 把 z 改成 0、0、0,重跑第四段。三个 sigmoid 都是 0.5,加起来 1.5。 即使 logits 全相等,多标签的三个概率也不归一——这是 sigmoid 的性质。
  3. 把第五段的概率改成 0.99、0.999,重跑。损失会到 0.01 和 0.001。 但永远不会是 0——因为 -ln(1) 才是 0,而概率到不了 1。

常见误区

  1. 「softmax 输出的是概率,所以可以做阈值判断」。 它可以当概率用,但多分类里各类是互相竞争的,阈值判断更适合多标签(sigmoid)。 多分类通常直接取最大那一类。
  2. 「多标签时概率加起来超过 1 是 bug」。 不是 bug。每类独立判断「是不是」,加起来可以是任何值。 实测三个类的 sigmoid 输出和是 2.3924。
  3. 「softmax 减最大值只是为了效率」。 是为了数值稳定。不减的话 exp 会溢出成无穷大,结果变成 nan。 logits 到几百的时候一定会遇到。
  4. 「回归问题也可以套 softmax」。 softmax 的输出一定在 0 到 1 之间且归一,回归的目标是任意实数。 用它做回归等于把答案限制在概率单纯形上。
自测三问(点开对答案) 1. 四种任务分别该用什么输出层? —— 回归用 1 个数不激活配平方误差;二分类用 1 个数过 sigmoid 配二元交叉熵; 多分类用类别数过 softmax 配交叉熵;多标签用类别数过 sigmoid,各类独立算二元交叉熵。 选 softmax 还是 sigmoid 只问一句:这些类互斥吗? 2. 为什么 softmax 的实现要先减掉最大值? —— 因为 exp 增长极快,logits 到 1000 时直接算会溢出成无穷大,除以无穷大得到 nan。 减去最大值之后指数最大是 0,绝不溢出;数学上分子分母同时缩放,比值不变。 实测 logits 为 1000、1001、1002 时,不稳定的写法全是 nan。 3. 交叉熵只看正确类的概率,会带来什么问题? —— 它不惩罚「把错误的类分得很细」。正确类 0.9,其余怎么分配损失都一样。 所以模型可能对错误类别给出很自信的排序,却没有任何代价。标签平滑就是用来缓解这个问题的。

到这里,卷积网络的每个部件都拆完了。 剩下最后一件事:把这些部件串起来,用梯度真正训练一遍。 卷积核的梯度怎么算?池化的梯度往哪传?下一节把这两件事对账。

相关笔记