Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

从概率到学习目标

前几章已经准备好了三件工具:用函数描述预测,用导数调整参数,用概率描述不确定性。现在把它们接起来。

训练前,我们必须告诉程序什么叫“更好”。预测耗时时,导读用了平方误差;预测一件事会不会发生时,机器学习代码却经常出现对数和交叉熵。

这些目标是随意挑的吗?这一章从一个很小的概率模型出发,看看它们怎样自然地出现。最后,再用一条直线做一次完整的数学篇实践。

1. 哪个概率更能解释已经看到的数据

假设我们记录了十次“任务是否在约定时间内完成”:7 次完成,记作 1;3 次没完成,记作 0。暂时不考虑文档之间的差别,假设每次独立,按时完成的概率都是 pp。

候选模型说 p=0.2p=0.2、0.50.5 或 0.70.7,哪一个更符合这十条记录?

对于一串已经观察到的标签,在这个独立伯努利模型下,它的概率为:

L(p)=p7(1−p)3 \mathcal L(p)=p^7(1-p)^3

L\mathcal L 叫似然。这里把数据固定下来,让候选参数 pp 变化,比较哪种参数让这份数据出现得更合理。

注意,我们关心的是这串具体的标签。如果只问“十次中恰好有七次成功,不管顺序”,还要乘组合数 (107)\binom{10}{7}。不过这个因子与 pp 无关,不会改变哪一个候选 pp 最好。

这也不是在求“参数 pp 本身的概率”。似然描述数据在给定参数下的概率或密度;若要求参数的后验分布,还需要像第三章那样明确先验等条件。

2. 为什么大家喜欢取对数

样本一多,许多小概率连乘,结果会小得难以表示。假如 1000 个因子都为 0.1,乘积就是 10−100010^{-1000},常见的浮点数已经无法正常保存它。

对数能把乘法变成加法:

log⁡(ab)=log⁡a+log⁡b \log(ab)=\log a+\log b

本章的 log⁡\log 都指自然对数,也写作 ln⁡\ln,底数为 ee。它在正数范围内严格递增,因此一个似然比另一个大,取对数后大小顺序仍然不变。

于是:

log⁡L(p)=7log⁡p+3log⁡(1−p) \log\mathcal L(p)=7\log p+3\log(1-p)

我们习惯把训练写成“最小化损失”,再加一个负号,就得到负对数似然,英文缩写 NLL:

L(p)=−7log⁡p−3log⁡(1−p) L(p)=-7\log p-3\log(1-p)

最大化似然与最小化负对数似然,会选出相同参数。把它再除以样本数 10,也不改变本次优化的最优位置,只是让不同规模的数据集更容易比较平均损失。

图 1:候选概率过低或过高,都不能很好解释这份同时含有 0 和 1 的数据。

如果想用上一章学到的导数检查,利用 (log⁡p)′=1/p(\log p)'=1/p,可以得到:

L′(p)=−7p+31−p L'(p)=-\frac7p+\frac3{1-p}

令它等于 0,整理后就是 10p=710p=7,所以 p^=0.7\hat p=0.7。在 0<p<10<p<1 内,这里的二阶导数为正,因此这个驻点确实是最低点。也可以直接看曲线或用网格搜索核对,不必现在就熟悉二阶导数。

这种让已观测数据的似然最大的参数估计,叫最大似然估计。得到 0.7,并不保证未来任务的真实概率恰好是 0.7:样本很少,模型的独立与同概率假设也可能不适合实际情况。

3. 从一个共同概率,到每条样本自己的预测

刚才所有任务共用一个 pp。更实用的模型会根据输入 xix_i,给第 ii 条任务预测一个概率 qiq_i。

令标签 yiy_i 为 0 或 1,单条样本的概率可以统一写为:

P(yi∣xi)=qiyi(1−qi)1−yi P(y_i\mid x_i)=q_i^{y_i}(1-q_i)^{1-y_i}

如果 yi=1y_i=1,式子留下 qiq_i;如果 yi=0y_i=0,式子留下 1−qi1-q_i。对它取负对数,就得到:

ℓi=−yilog⁡qi−(1−yi)log⁡(1−qi) \ell_i=-y_i\log q_i-(1-y_i)\log(1-q_i)

这叫二元交叉熵损失。平均所有样本:

L=1n∑i=1nℓi L=\frac1n\sum_{i=1}^{n}\ell_i

当真实标签是 1,模型给出 0.9,损失约为 −log⁡0.9=0.105-\log0.9=0.105;若只给 0.01,损失约为 4.605。模型越自信地把真实结果说成“不可能”,代价就越大。

严格的 0 概率会让真实发生事件的损失变为无穷。实现时需要留意数值稳定性,不能先把极小的概率算成 0,再指望取对数恢复它。第七章会从模型的原始分数出发,使用稳定的计算方式。

多分类也是同一个想法:模型给每个类别一个概率,观察到哪个类别,就用那个类别的负对数概率作为损失。预测下一个 Token 的语言模型,也会用到这样的目标,只是类别变成了词表中的 Token,并且概率以已有上下文为条件。

4. 熵:这份分布本身有多难猜

先不讨论训练,把注意力放在一个已知的离散分布 pp 上。它的各类概率记作 p1,…,pkp_1,\ldots,p_k。

一次结果越少见,看到它时越“意外”。可以用 −log⁡pj-\log p_j 给这种意外程度赋一个数值。把所有可能结果按真实概率加权平均,就得到熵:

H(p)=−∑j=1kpjlog⁡pj H(p)=-\sum_{j=1}^{k}p_j\log p_j

比如两个类别的概率为 p=[0.75,0.25]p=[0.75,0.25],熵约为 0.562。若两个类别各占一半,则熵为 log⁡2≈0.693\log2\approx0.693,平均更难猜;若结果完全确定,熵就是 0。

这里使用自然对数,单位常称为 nat;若改用以 2 为底的对数,单位就是 bit。换底只会整体缩放数值,不改变哪种预测更好。

这不是说某个具体对象“有多少知识”,也不是说熵越低就越有价值。它衡量的是一个给定概率分布中的不确定性,必须先说清楚分布描述什么。

5. 交叉熵:真实分布和预测分布不一样时

现在真实分布仍是 pp,但我们按另一个分布 qq 预测。结果按 pp 发生,意外程度却按模型的 −log⁡qj-\log q_j 计算,于是得到交叉熵:

H(p,q)=−∑j=1kpjlog⁡qj H(p,q)=-\sum_{j=1}^{k}p_j\log q_j

如果 p=[0.75,0.25]p=[0.75,0.25],而模型只给出 q=[0.5,0.5]q=[0.5,0.5],交叉熵为 0.693。若模型恰好使用 q=pq=p,交叉熵就回到熵 0.562。

两者的差可以写成:

DKL(p∥q)=∑j=1kpjlog⁡pjqj=H(p,q)−H(p) \begin{aligned} D_{\mathrm{KL}}(p\|q) &=\sum_{j=1}^{k}p_j\log\frac{p_j}{q_j}\\ &=H(p,q)-H(p) \end{aligned}

这叫 KL 散度。对于合法的离散分布,它非负;两个分布相同则为 0。如果某个类别在 pp 中概率大于 0,却被 qq 赋成 0,散度会是无穷。

图 2:固定真实分布 pp 时,H(p)H(p) 不变,最小化交叉熵也就等价于最小化 DKL(p∥q)D_{\mathrm{KL}}(p\|q)。

KL 散度能衡量分布差异,但不是普通的几何距离。尤其是,交换顺序通常会改变结果:

DKL(p∥q)≠DKL(q∥p) D_{\mathrm{KL}}(p\|q)\ne D_{\mathrm{KL}}(q\|p)

本例两个方向分别约为 0.131 和 0.144。不要因为符号中间看起来像一条分隔线,就认为左右对称。

单条标签怎么变成一个分布

如果一条样本的真实类别是第 2 类,可以用 p=[0,1,0]p=[0,1,0] 表示它。这个只有真实类别位置为 1、其余为 0 的向量叫 one-hot 向量。

把它代入交叉熵,零项都不贡献,剩下:

H(p,q)=−log⁡q2 H(p,q)=-\log q_2

这就接回了前面的分类损失。单条标签的 one-hot 表示,不代表真实世界对这个输入完全没有随机性;它只是这次实际观测结果的编码方式。

对于 pj=0p_j=0 的项,我们采用连续极限约定,让它贡献 0。代码会明确跳过这些项,避免直接计算 0log⁡00\log0。

6. 平方误差也能从概率出发

再回到打印耗时。假设模型预测的是条件均值 fθ(x)f_{\boldsymbol\theta}(x),真实耗时还叠加了一份噪声:

yi=fθ(xi)+εi y_i=f_{\boldsymbol\theta}(x_i)+\varepsilon_i

θ\boldsymbol\theta 表示所有待训练参数。例如,直线模型中就是斜率和固定项。假设噪声彼此独立,都服从均值为 0、方差为固定 σ2\sigma^2 的正态分布。把预测值简记为 y^i=fθ(xi)\hat y_i=f_{\boldsymbol\theta}(x_i),单条样本的条件密度为:

p(yi∣xi,θ)=12πσexp⁡ ⁣(−(yi−y^i)22σ2) p(y_i\mid x_i,\boldsymbol\theta) =\frac1{\sqrt{2\pi}\sigma} \exp\!\left( -\frac{(y_i-\hat y_i)^2}{2\sigma^2} \right)

这里是概率密度,不是连续变量恰好等于某个实数的概率。和第三章一样,不要把密度的高度当作事件概率。

把各样本密度相乘、取负对数,可以整理为:

−log⁡L(θ)=C+12σ2∑i=1n(yi−fθ(xi))2 -\log\mathcal L(\boldsymbol\theta) =C+\frac1{2\sigma^2} \sum_{i=1}^{n} (y_i-f_{\boldsymbol\theta}(x_i))^2

CC 不随模型参数变化。在 σ\sigma 固定时,最小化这个式子,就是最小化平方误差之和;再除以 nn,就是 MSE。

原来,平方误差与正态噪声模型可以联系起来。

这不表示使用 MSE 前必须证明所有噪声都正态。即使不采用这个概率解释,MSE 仍可以作为一个明确的优化目标;只是不能顺手把正态模型下的全部结论都带过去。极端误差会被平方放大,如果任务更在意典型绝对偏差,也可能选择其他目标。

7. 手算、画图,再用代码核对

下面的脚本使用 Python 3.9+ 标准库,比较不同候选概率,并计算熵、交叉熵和两个方向的 KL 散度:

python3 src/examples/learning_objectives.py

也可以下载脚本,执行 python3 learning_objectives.py。

"""第 5 章:伯努利似然、交叉熵与 KL 散度;自然对数,单位 nat。"""

from math import exp, inf, isclose, log


LABELS = [1] * 7 + [0] * 3


def bernoulli_nll(labels, probability):
    if not 0 < probability < 1:
        raise ValueError("本例的候选概率必须严格位于 0 和 1 之间")
    return -sum(log(probability) if y == 1 else log(1 - probability) for y in labels)


def cross_entropy(p, q):
    if len(p) != len(q) or not p:
        raise ValueError("两个分布必须非空且类别一一对应")
    for distribution in (p, q):
        if any(value < 0 for value in distribution) or not isclose(sum(distribution), 1):
            raise ValueError("概率必须非负且总和为 1")
    result = 0.0
    for actual, predicted in zip(p, q):
        if actual == 0:
            continue  # 采用 0 log 0 = 0 的连续极限约定
        if predicted == 0:
            return inf
        result -= actual * log(predicted)
    return result


def main():
    for probability in [0.2, 0.5, 0.7, 0.9]:
        value = bernoulli_nll(LABELS, probability)
        likelihood = exp(-value)
        print(f"p={probability:.1f},似然={likelihood:.6f},平均负对数似然={value / len(LABELS):.6f}")
    candidates = [i / 100 for i in range(1, 100)]
    best = min(candidates, key=lambda p: bernoulli_nll(LABELS, p))
    print(f"网格搜索选择的概率:{best:.2f}")
    p, q = [0.75, 0.25], [0.5, 0.5]
    entropy = cross_entropy(p, p)
    cross = cross_entropy(p, q)
    reverse_kl = cross_entropy(q, p) - cross_entropy(q, q)
    print(f"H(p)={entropy:.6f},H(p,q)={cross:.6f}")
    print(f"KL(p||q)={cross - entropy:.6f},KL(q||p)={reverse_kl:.6f}")


if __name__ == "__main__":
    main()

结果为:

p=0.2,似然=0.000007,平均负对数似然=1.193550
p=0.5,似然=0.000977,平均负对数似然=0.693147
p=0.7,似然=0.002224,平均负对数似然=0.610864
p=0.9,似然=0.000478,平均负对数似然=0.764528
网格搜索选择的概率:0.70
H(p)=0.562335,H(p,q)=0.693147
KL(p||q)=0.130812,KL(q||p)=0.143841

这段代码没有使用分类模型,只是在一组候选概率中比较目标函数。先把“为什么这个损失更小”看清楚,再用梯度去训练复杂模型,会容易很多。

本例 0.01 间隔的网格恰好包含 0.7。换成别的数据,最优概率未必恰好落在这些候选值上。和导读的枚举一样,搜索结果受候选范围与精度限制。

8. 第一篇实践:让一条直线把五章串起来

现在可以做一次完整的小实验了。目标很具体:拟合一条直线,看看学习率、数据噪声和样本数量分别会造成什么变化。

我们自己生成数据:

y=3x+20+ε,ε∼N(0,σ2) y=3x+20+\varepsilon,\qquad \varepsilon\sim\mathcal N(0,\sigma^2)

xx 是在 5~25 页之间均匀抽取的整数,σ\sigma 分别设为 2 秒或 10 秒。真实关系是我们规定的,所以能知道模型到底偏离了这条线多少。这是模拟实验,不是对真实打印机的性能报告。

先把计算尺度整理一下

直接用页数训练,斜率梯度会乘上较大的 xx。为了更容易选择学习率,我们把已知输入范围换算为:

z=x−1510 z=\frac{x-15}{10}

于是 zz 落在 [−1,1][-1,1] 内,训练的模型是 y^=az+b\hat y=az+b。这里的 15 和 10 来自实验预先规定的范围,不是拿测试结果挑出的数字。

要换回原始页数的表达,只需展开:

az+b=a10x+(b−3a2) az+b=\frac a{10}x+\left(b-\frac{3a}{2}\right)

因此,归一化后的 a=30,b=65a=30,b=65,就对应原来的斜率 3、固定项 20。变换坐标并没有改变我们能表达的这组直线。

实验一:只改变学习率

固定 80 条、噪声标准差为 2 秒的训练数据,从相同初值开始,分别用 0.02、0.2、1.1 的学习率更新:

图 3:纵轴采用对数刻度,每跨一个相同的刻度距离,表示相同的倍数变化。发散曲线达到演示阈值后提前停止。

这次不比较哪个模型泛化更好,只看同一个目标怎样被优化。小步子可能只是还没走够;发散则需要重新考虑步长和尺度。

实验二:改变噪声和样本量

然后固定学习率和优化方式,比较四种组合:8 条或 80 条样本,噪声标准差为 2 秒或 10 秒。每种设置用 30 个种子重新生成数据并训练。

对每个模型,在 5~25 页的整数网格上,计算它与无噪声真函数 3x+203x+20 的平均绝对差,下面称为“参考 MAE”。

这不是新任务上的实际预测误差:真实新任务还有噪声。我们只是借助模拟中已知的真函数,单独观察拟合本身偏到了哪里。真实项目不知道这条真线,需要独立的验证与测试数据;第六章会采用那样的流程。

图 4:每个点代表一次重新生成数据并训练的实验,橙色短线是 30 次结果的平均值。散点的分散程度不是均值的置信区间。

把实验跑起来

实践脚本同样只使用 Python 标准库。运行:

python3 src/examples/foundations_practice.py

下面保留完整实现,方便你改动生成数据和训练部分:

"""第一篇实践:已知生成规律的模拟实验;没有真实设备数据。"""

from math import isfinite
from random import Random
from statistics import mean, stdev


def make_data(count, noise, seed):
    rng = Random(seed)
    data = []
    for _ in range(count):
        pages = rng.randint(5, 25)
        seconds = 3 * pages + 20 + rng.gauss(0, noise)
        data.append((pages, seconds))
    return data


def predict(pages, a, b):
    # 让输入落在 [-1, 1],避免原始页数尺度使步长过大。
    return a * ((pages - 15) / 10) + b


def fit(data, rate=0.2, steps=1000):
    a, b = 0.0, 0.0
    history = [mean(y*y for _, y in data)]
    for _ in range(steps):
        errors = [predict(x, a, b) - y for x, y in data]
        da = 2 * mean(error * ((x-15)/10) for error, (x, _) in zip(errors, data))
        db = 2 * mean(errors)
        a, b = a - rate * da, b - rate * db
        loss = mean((predict(x, a, b) - y)**2 for x, y in data)
        history.append(loss)
        if not isfinite(loss) or loss > 1e12:
            break  # 停止明显发散的演示,避免继续放大数值。
        if max(abs(da), abs(db)) < 1e-10:
            break
    return a, b, history


def reference_mae(a, b):
    # 仅在模拟中知道无噪声真函数,用固定网格衡量拟合的偏离。
    pages = range(5, 26)
    return mean(abs(predict(x, a, b) - (3*x+20)) for x in pages)


def repeated_errors(count, noise):
    errors = []
    for seed in range(1000, 1030):
        a, b, _ = fit(make_data(count, noise, seed))
        errors.append(reference_mae(a, b))
    return errors


def main():
    data = make_data(80, noise=2, seed=2026)
    for rate in [0.02, 0.2, 1.1]:
        a, b, history = fit(data, rate, steps=120)
        if not isfinite(history[-1]) or history[-1] > 1e12:
            print(f"学习率 {rate:g}:损失过大,在第 {len(history)-1} 步停止")
        else:
            print(f"学习率 {rate:g}:120 步后训练 MSE={history[-1]:.3f}")
    a, b, _ = fit(data)
    print(f"换回页数单位的直线:y ≈ {a/10:.3f} x + {b-1.5*a:.3f}")
    for noise in [2, 10]:
        for count in [8, 80]:
            errors = repeated_errors(count, noise)
            print(f"噪声标准差={noise},样本数={count}:参考 MAE 均值={mean(errors):.3f},实验间标准差={stdev(errors):.3f}")


if __name__ == "__main__":
    main()

本次运行得到:

学习率 0.02:120 步后训练 MSE=7.236
学习率 0.2:120 步后训练 MSE=4.722
学习率 1.1:损失过大,在第 42 步停止
换回页数单位的直线:y ≈ 2.999 x + 19.478
噪声标准差=2,样本数=8:参考 MAE 均值=0.732,实验间标准差=0.365
噪声标准差=2,样本数=80:参考 MAE 均值=0.280,实验间标准差=0.146
噪声标准差=10,样本数=8:参考 MAE 均值=3.661,实验间标准差=1.825
噪声标准差=10,样本数=80:参考 MAE 均值=1.398,实验间标准差=0.728

数据越多,平均拟合偏差在这组模拟中越小;噪声更大,拟合也更容易摇摆。但别把四个平均数读成“每增加十倍样本,所有任务都会改善同样多”。这里的结论依赖于我们设定的线性真函数、独立正态噪声和输入范围。

如果目标关系本来就不是直线,再增加样本,也不能让一条直线完整表示所有弯曲。数据数量、模型表达能力和优化过程,是三个不同的问题。

最后留下几行自己的实验记录:用了哪些设置、原先猜会怎样、实际发生了什么,以及这个模拟还没有验证什么。它们会在后续章节的模型比较中继续派上用场。

9. 自测与小实验

  1. 最大化似然为什么可以改写成最小化负对数似然?再除以固定样本数,会改变最优参数吗?
  2. 十次独立观测中有八个 1、两个 0,使用共同伯努利概率时,最大似然估计是多少?
  3. 标签为 1 时,模型给出 0.9 和 0.1,哪一个交叉熵损失更大?
  4. 对 p=[0.75,0.25]p=[0.75,0.25],为什么 H(p,q)H(p,q) 在 q=pq=p 时达到最小值?这是否表示熵也一定等于 0?
  5. 若真实可能发生的类别被预测为概率 0,交叉熵会怎样?
  6. 什么时候本章的正态噪声负对数似然与 MSE 有相同的最优模型参数?
  7. 实践中若得到 a=28,b=66a=28,b=66,换回原始页数后,直线是什么?增加样本量能保证训练损失每次都下降吗?
展开参考答案
  1. 对数严格递增,保留大小顺序;负号把最大化变成最小化。除以同一个正数也不改变最优位置。
  2. 为 8/10=0.88/10=0.8。它是这份数据下的估计,不是真实未来概率的保证。
  3. 0.1 的损失更大,约为 2.303;0.9 的损失约为 0.105。
  4. 因为 H(p,q)=H(p)+DKL(p∥q)H(p,q)=H(p)+D_{\mathrm{KL}}(p\|q),KL 散度非负,两个分布相同时为 0。此时交叉熵等于熵,但这个 pp 的熵仍大于 0。
  5. 会成为无穷。实现中需要注意端点与数值稳定性,不能把数值下溢误当作真实概率为 0。
  6. 在本章给定的独立、同方差正态噪声模型下,且 σ\sigma 固定时,与模型参数有关的项只剩正倍数的平方误差之和。
  7. 为 y^=2.8x+24\hat y=2.8x+24。增加数据不保证训练损失每次下降:新样本可能更难、噪声也不同;更多数据带来的价值主要在于更可靠地估计目标关系,而不是让旧的训练分数越来越漂亮。

进阶选做:给真函数加上 0.05(x−15)20.05(x-15)^2,仍然只拟合直线。重做噪声与样本量实验,观察模型表达能力不够时,增加数据能解决哪些问题,又留下哪些问题。

第一篇到这里告一段落。接下来进入线性回归:第一个完整模型,把数据划分、训练、正则化和评估放到同一个实际流程中。

想进一步核对概率解释,可以选读 Stanford CS229 讲义中线性回归的概率解释部分;关于熵、交叉熵和 KL 散度,可参阅《深度学习》的概率与信息论章节。先围绕本章例子查阅即可,不必一口气读完整章。