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

观察到 100 次抛硬币有 70 次正面,为什么答案就是 0.70? 因为 0.70 是让「看到这份数据」的概率最大的那个值——这叫最大似然。 把似然取对数、取负号、除以样本数,得到的就是交叉熵损失。 它不是设计出来的,是推出来的。 平方误差也有同样的来路:它对应「噪声是高斯分布」这个假设。

📝 本节和前面几节的关系

上一节看到交叉熵比平方误差好用,但没说它凭什么。 这一节补上这个推导。 它不长,但它是理解「为什么这个损失配这个输出层」的唯一途径。 推导的起点是一个很朴素的问题:给定数据,参数应该是多少? 最大似然给的回答是:让这份数据出现概率最大的那个参数。

一、把数据当已知,把参数当未知

平时我们想的是「参数定了,数据会是什么样」。 似然函数把这个方向反过来:数据已经看到了,问「参数取什么值时,看到这份数据的可能性最大」。 抛 100 次、70 次正面,正面概率记作 p。 那么「恰好看到这份数据」的概率是:

L(p) = p 的 70 次方 * (1 - p) 的 30 次方

这个式子的自变量是 p,数据(70 和 30)是固定的常数。 这就是似然和概率的区别:概率是数据的函数,似然是参数的函数。

二、算一遍,看最大值在哪

取几个候选 p 代进去:

候选 p 似然 对数似然
0.50 7.888609e-31 -69.3147
0.60 3.407119e-28 -63.2465
0.65 1.682582e-27 -61.6495
0.68 2.692299e-27 -61.1794
0.70 2.954612e-27 -61.0864
0.72 2.679265e-27 -61.1843
0.75 1.557717e-27 -61.7266
0.80 1.766847e-28 -63.9032
0.90 6.265787e-34 -76.4528
似然那一列小得没法比(10 的负 31 次方),所以实际都看对数似然。
对数把连乘变成连加,也把极小的数变成几十的负数,好算也好比。
关键是:对数函数是单调递增的,所以「让对数最大」和「让原式最大」是同一件事。
最大值落在 0.70。
在 0.001 的网格上扫一遍,结果也是 0.700。
这和「正面次数除以总次数」完全一致。
所以那个教科书答案不是拍脑袋,它就是最大似然解。

三、从似然推到交叉熵

把每个样本看成一次伯努利试验。 n 个样本的似然是它们概率的连乘:

L = 连乘 [ a_i 的 t_i 次方 * (1 - a_i) 的 (1 - t_i) 次方 ]

a_i 是网络对这个样本的输出(预测概率),
t_i 是标签(0 或 1)。

连乘容易下溢——100 个 0.7 相乘已经到 10 的负 16 次方。 所以取对数,连乘变连加:

ln L = 求和 [ t_i * ln(a_i) + (1 - t_i) * ln(1 - a_i) ]

我们要最大化这个式子。习惯上把「最大化」改成「最小化负号」,再除以样本数变成平均值,就得到:

L = -1/n * 求和 [ t * ln(a) + (1 - t) * ln(1 - a) ]

这就是二分类的交叉熵损失。 注意整个过程没有任何「设计」的动作:只有「假设样本独立」和「用最大似然定参数」两个前提。

四、平方误差也有它的来路

如果假设噪声是高斯分布,推出来的就是平方误差。 高斯分布的概率密度里有个 exp(负的误差平方除以 2 倍方差),取对数之后,那个平方项就留下来了。 所以两种损失不是「谁更对」,是「假设不同」:

损失 背后的假设 适合的场景
平方误差 误差服从高斯分布 回归,输出是实数
交叉熵 标签服从伯努利分布 分类,输出是概率
分类问题的标签只有 0 和 1,说它是伯努利更贴;回归问题的标签是任意实数,说它是高斯更贴。
这就是「交叉熵配分类、平方误差配回归」这条惯例的来源。

五、两种损失惩罚错误的陡度

同一次「猜错」,两种损失各罚多少:

候选 p 负对数似然 平方误差
0.90 0.7645 0.2500
0.95 0.9346 0.2725
0.99 1.3886 0.2941
真实比例是 0.70,候选从 0.90 一路挪到 0.99。
交叉熵从 0.76 涨到 1.39,几乎翻倍;平方误差从 0.25 只涨到 0.29。
因为平方误差有上界:标签是 0 或 1,最大也就是 1。
交叉熵没有上界,p 趋近于 0 时它趋近于无穷。
所以交叉熵对「几乎不可能还发生」这件事罚得极重,平方误差罚得很轻。
这也解释了上一节那张梯度表:交叉熵在饱和时梯度反而最大,因为它对离谱的错误毫不手软。

六、动手 Demo:交叉熵不是设计出来的,是推出来的

这段代码用一个 100 次、70 次正面的样本,把似然函数、对数似然、最大似然解都算一遍; 最后比两种损失在「猜错」时的惩罚有多陡。 重点看三处。第二段似然从 1e-31 涨到 1e-27 再掉回 1e-34;第三段网格扫描落在 0.700; 第六段 0.99 时交叉熵 1.3886 对平方误差 0.2941。

# -*- 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))


rg = np.random.default_rng(3)
n = 100
y = (rg.random(n) < 0.7).astype(float)
k = int(y.sum())

print('[1] 一个最朴素的问题:观察到 %d 次正面、%d 次反面,硬币正面的概率是多少' % (k, n - k))
print('  教科书答案是 %d/%d = %.4f,但那个数是从哪来的' % (k, n, k / n))
print('')

print('[2] 似然函数:把「数据已经固定」,反过来当成「概率的函数」')
print('  似然 L(p) = p 的 k 次方 * (1-p) 的 (n-k) 次方')
print('  它回答的是:「如果正面概率是 p,那么恰好看到这份数据的可能性有多大」')
print('')
print('  候选 p     似然                 对数似然      负对数似然(总和)')
best_p, best_ll = 0.0, -1e18
for p in (0.50, 0.60, 0.65, 0.68, 0.70, 0.72, 0.75, 0.80, 0.90):
    L = p ** k * (1.0 - p) ** (n - k)
    ll = k * np.log(p) + (n - k) * np.log(1.0 - p)
    if ll > best_ll:
        best_ll, best_p = ll, p
    print('  ' + pad('%.2f' % p, 11) + pad('%.6e' % L, 21)
          + pad('%.4f' % ll, 14) + '%.4f' % (-ll))
print('')

print('[3] 在网格上找最大值,看它落在哪')
grid = np.linspace(0.01, 0.99, 981)
lls = k * np.log(grid) + (n - k) * np.log(1.0 - grid)
i = int(np.argmax(lls))
print('  网格步长 0.001,最大值在 p = %.3f' % grid[i])
print('  和「正面次数除以总次数」的 %.4f 完全一致' % (k / n))
print('  所以「频率估计」不是拍脑袋,它就是最大似然解')
print('')

print('[4] 最大似然怎么变成交叉熵的')
print('  把每个样本看成一次伯努利试验,似然 = 每个样本概率的连乘')
print('  连乘容易下溢(100 个 0.7 相乘已经很小),所以取对数变连加')
print('  再取负号、除以样本数,就是平均负对数似然:')
print('    -1/n * sum[ t*ln(a) + (1-t)*ln(1-a) ]')
print('  这正是二分类的交叉熵损失。它不是「设计」出来的,是推出来的')
print('')

print('[5] 那平方误差为什么也算「合理」')
print('  平方误差对应的是「噪声是高斯分布」这个假设下的最大似然')
print('  两个假设不一样,导出的损失就不一样;哪个更贴数据,哪个就更好用')
print('  分类问题的标签是 0 和 1,用伯努利假设更贴;回归问题的标签是实数,用高斯假设更贴')
print('')

print('[6] 数值比一下:两种损失在「猜错」时的惩罚有多陡')
print('  真实比例 %.2f;候选 p 从 0.70 挪到 0.90,偏离 0.20' % (k / n))
for p in (0.90, 0.95, 0.99):
    nll = -(k * np.log(p) + (n - k) * np.log(1.0 - p)) / n
    mse = np.mean((y - p) ** 2)
    print('  ' + pad('p = %.2f' % p, 11) + pad('负对数似然 %.4f' % nll, 20)
          + '平方误差 %.4f' % mse)
print('  交叉熵涨得快得多:它把「几乎不可能还发生」这件事罚得很重')
print('  平方误差有上界(最大就是 1),所以它对离谱的错误不够敏感')

实跑输出:

--------------------------------------------------------------------
似然函数:交叉熵不是设计出来的,是推出来的
--------------------------------------------------------------------
[1] 一个最朴素的问题:观察到 70 次正面、30 次反面,硬币正面的概率是多少
  教科书答案是 70/100 = 0.7000,但那个数是从哪来的

[2] 似然函数:把「数据已经固定」,反过来当成「概率的函数」
  似然 L(p) = p 的 k 次方 * (1-p) 的 (n-k) 次方
  它回答的是:「如果正面概率是 p,那么恰好看到这份数据的可能性有多大」

  候选 p     似然                 对数似然      负对数似然(总和)
  0.50       7.888609e-31         -69.3147      69.3147
  0.60       3.407119e-28         -63.2465      63.2465
  0.65       1.682582e-27         -61.6495      61.6495
  0.68       2.692299e-27         -61.1794      61.1794
  0.70       2.954612e-27         -61.0864      61.0864
  0.72       2.679265e-27         -61.1843      61.1843
  0.75       1.557717e-27         -61.7266      61.7266
  0.80       1.766847e-28         -63.9032      63.9032
  0.90       6.265787e-34         -76.4528      76.4528

[3] 在网格上找最大值,看它落在哪
  网格步长 0.001,最大值在 p = 0.700
  和「正面次数除以总次数」的 0.7000 完全一致
  所以「频率估计」不是拍脑袋,它就是最大似然解

[4] 最大似然怎么变成交叉熵的
  把每个样本看成一次伯努利试验,似然 = 每个样本概率的连乘
  连乘容易下溢(100 个 0.7 相乘已经很小),所以取对数变连加
  再取负号、除以样本数,就是平均负对数似然:
    -1/n * sum[ t*ln(a) + (1-t)*ln(1-a) ]
  这正是二分类的交叉熵损失。它不是「设计」出来的,是推出来的

[5] 那平方误差为什么也算「合理」
  平方误差对应的是「噪声是高斯分布」这个假设下的最大似然
  两个假设不一样,导出的损失就不一样;哪个更贴数据,哪个就更好用
  分类问题的标签是 0 和 1,用伯努利假设更贴;回归问题的标签是实数,用高斯假设更贴

[6] 数值比一下:两种损失在「猜错」时的惩罚有多陡
  真实比例 0.70;候选 p 从 0.70 挪到 0.90,偏离 0.20
  p = 0.90   负对数似然 0.7645   平方误差 0.2500
  p = 0.95   负对数似然 0.9346   平方误差 0.2725
  p = 0.99   负对数似然 1.3886   平方误差 0.2941
  交叉熵涨得快得多:它把「几乎不可能还发生」这件事罚得很重
  平方误差有上界(最大就是 1),所以它对离谱的错误不够敏感

第二段那个「似然」列几乎没法看,全是 10 的负 30 次方上下。 这不是数值不稳定,是连乘的真实结果——100 个概率相乘本来就这么小。 所以实际计算一律用对数,从第一步就开始取对数,不等连乘完再取。 第三段用 981 个点的网格扫,最大值落在 0.700。 这里没有用任何求导、任何优化器——只是把候选值代进去比大小。 一维问题可以这么粗暴地解,这也是「最大似然」这个名字最朴素的体现。 第四段的推导里,唯一需要留意的假设是「样本独立」。 独立才能把概率写成连乘;不独立的话似然函数根本写不出来。 真实数据里样本之间常常有相关性(比如同一段视频的相邻帧),那时这个推导就只是近似。 第六段那个「平方误差有上界」值得记住:标签是 0 或 1,所以 (a 减 t) 的平方最大就是 1。 交叉熵没有上界,p 越接近 0 越大。 一个损失函数有没有上界,决定了它对「灾难性错误」的敏感程度。 代码里 k 是正面的次数,n - k 是反面,都从随机数据里数出来。 所以「70 次」不是写死的,是这一份数据恰好 70 次——换个种子会变成 68 或 72,但结论一样。

30 秒实验

  1. 把 n 从 100 改成 10,重跑。 似然的最大值点会从 0.700 移到 0.7 附近的整数比上(比如 7/10)。 样本越少,最大似然解越不稳定——这就是「小样本估计不可靠」的数学表述。
  2. 把 y 的生成概率从 0.7 改成 0.3,重跑。最大似然解跟着变成 0.3 附近。 似然函数只认数据,不认任何先验。
  3. 把第六段的候选 p 加到 1.0,重跑。负对数似然会变成无穷大(log(0))。 代码里用 np.log 会给出警告,所以实际实现都要加一个极小数做保护。

常见误区

  1. 「似然就是概率」。 概率是「参数已知,数据会怎样」;似然是「数据已知,参数该是多少」。 两者表达式一样,但谁是变量、谁是常数正好相反。
  2. 「交叉熵是人为设计出来让梯度变好看的」。 它是从「样本独立 + 最大似然」两个前提推出来的,没有调参的余地。 梯度好看是推出来的副产品,不是设计目标。
  3. 「平方误差不如交叉熵,应该全部换掉」。 回归问题上平方误差就是高斯的最大似然解,完全正确。 该换的是「分类问题用平方误差」这种搭配。
  4. 「最大似然总能给出正确答案」。 它给的是「最符合观测数据的参数」,样本少或数据有偏时它会错得很自信。 加先验(最大后验)或加正则,就是在修这个问题。
自测三问(点开对答案) 1. 似然函数和概率函数的区别是什么? —— 表达式可以一样,但角色相反。概率函数里参数是常数、数据是变量; 似然函数里数据是常数、参数是变量。似然回答的是「参数取什么值,看到这份数据的可能性最大」。 2. 交叉熵损失是怎么从似然推出来的? —— 假设样本独立,n 个样本的似然是各自概率的连乘。 取对数把连乘变连加,再取负号、除以样本数变成平均值,就得到平均负对数似然。 它的形式正好是交叉熵,整个推导只有「独立」一个假设。 3. 为什么分类用交叉熵、回归用平方误差? —— 因为两种损失对应两种不同的分布假设。交叉熵对应伯努利分布,适合标签是 0 或 1 的分类; 平方误差对应高斯分布,适合标签是实数的回归。选哪个取决于「你认为数据是怎么生成的」。

损失定了、激活定了,剩下的问题是怎么把梯度用起来。 每步用多少样本算梯度?全批量准但慢,纯随机快但抖。 下一节把三种做法放在一起,比步数、比耗时、比曲线。

相关笔记