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,“没有超时”记成 0,然后继续拟合直线。但直线可能输出 −0.3 或 1.4。拿它们当概率,就说不通了。我们需要一个把任意实数压进 0~1 的函数。

1. 给直线的输出加一道弯

先计算一个分数:

s=wTx+b s=\boldsymbol w^\mathsf T\boldsymbol x+b

再经过 Sigmoid 函数:

p=σ(s)=11+e−s p=\sigma(s)=\frac1{1+e^{-s}}

s=0s=0 时,p=0.5p=0.5;ss 很大时接近 1,很小时接近 0。这里 ee 是自然对数的底,与第五章的对数相对应。我们把 pp 解释为模型预测的超时概率。

图 1:分数变动相同幅度,概率不一定变动相同幅度。在两端,曲线会逐渐变平。

这就是逻辑回归。名字里有“回归”,常见用途却是分类。它先预测概率,再根据规则给出类别。

把公式稍作整理,还能得到:

log⁡p1−p=s \log\frac p{1-p}=s

p/(1−p)p/(1-p) 叫作胜算,例如 p=0.75p=0.75 时胜算为 3,表示发生与不发生的概率之比是 3∶1。逻辑回归对这个比值的对数建立线性模型,而不是要求概率本身沿直线变化。

2. 用交叉熵训练概率

第五章已经认识了二分类交叉熵。对于标签 y∈{0,1}y\in\{0,1\}:

ℓ=−ylog⁡p−(1−y)log⁡(1−p) \ell=-y\log p-(1-y)\log(1-p)

如果实际超时,模型却给了极小概率,损失就很大。训练会推动模型减少这种“很有把握却猜错”的情况。

结合 Sigmoid,损失对分数的导数有一个简洁结果:

∂ℓ∂s=p−y \frac{\partial\ell}{\partial s}=p-y

可以这样核对:σ′(s)=p(1−p)\sigma'(s)=p(1-p),而交叉熵对 pp 的导数是 (p−y)/(p(1−p))(p-y)/(p(1-p));相乘后分母正好消去。于是:

∂L∂wj=1n∑i(pi−yi)xij∂L∂b=1n∑i(pi−yi) \begin{aligned} \frac{\partial L}{\partial w_j} &=\frac1n\sum_i(p_i-y_i)x_{ij}\\ \frac{\partial L}{\partial b} &=\frac1n\sum_i(p_i-y_i) \end{aligned}

这里 LL 是平均交叉熵,xijx_{ij} 是第 ii 条记录的第 jj 个特征。它与线性回归的梯度很相似:都有“预测与实际的差,再乘输入”。损失和输出函数不同,最终得到的差恰好也很简洁。

本章代码再加上 0.05∑jwj20.05\sum_j w_j^2,所以权重梯度多出 0.1wj0.1w_j。这有助于约束有限样本中的权重;若数据恰好能被完美分开,无正则项的逻辑回归甚至可能不断增大权重,趋近更小损失却没有有限的最优权重。

3. 概率和行动,中间还隔着一个阈值

模型给出超时概率 0.4,要不要提前提醒用户?答案取决于误报和漏报各有多大代价。

规定 p≥tp\ge t 时报警,tt 就是分类阈值。常见的 0.5 只是一个选择。如果漏报更麻烦,可以降低阈值,让模型更愿意报警。

实际情况报警不报警
超时真正例 TP假负例 FN,漏报
未超时假正例 FP,误报真负例 TN

这里“正例”指超时,与好坏无关。我们关心的两个比例是:

精确率=TPTP+FP召回率=TPTP+FN \begin{aligned} \text{精确率}&=\frac{TP}{TP+FP}\\ \text{召回率}&=\frac{TP}{TP+FN} \end{aligned}

精确率回答“报警中有多少真的超时”;召回率回答“真实超时中找出了多少”。一个看预测出来的集合,一个看实际发生的集合。分母为零时,指标没有通常的比例解释;本章代码约定返回 0,便于展示,但分析时仍应说明没有预测正例或没有实际正例。

准确率为 (TP+TN)/n(TP+TN)/n。若一千次任务只有十次超时,永远说“不超时”也有 99% 准确率,却一个超时都没找出。这就是类别不平衡为什么会使单一准确率具有迷惑性。

本次教学实验把一次误报代价定为 1,一次漏报代价定为 3:

C=FP+3FN C=FP+3FN

这个代价来自我们对任务的设定,不是算法自动发现的事实。阈值在验证集上选择,测试集只负责评估选好后的规则。

图 2:三种候选阈值都使用同一组模型参数。改变阈值没有重新训练模型。

如果概率估计完全可靠,且只有这两种固定误判代价,可以直接比较期望代价:报警花费 1−p1-p,不报警花费 3p3p,所以 p≥0.25p\ge0.25 时报警。现实中的概率可能不够准确,代价也可能变化,因此还需要验证数据和实际反馈。

一个模型给很多任务打出 0.8,并不意味着其中一定有 80% 超时。这种概率与发生频率是否相符的问题叫作校准。交叉熵训练提供了学习概率的目标,却不是校准已完成的证明。

4. 跑一个两特征分类器

我们用页数和队列长度预测超时。数据仍是模拟的:先根据两个特征计算概率,再随机抽取 0 或 1。相似任务因此可能得到不同标签,模型不能只靠背下一个确定阈值完成任务。

代码用事先规定的范围缩放特征:页数减 20 后除以 10,队列长度减 5 后除以 5。这些常数来自生成规则,没有从测试数据学习。

下载脚本,只需 Python 标准库:

python3 src/examples/classification.py
"""第 7 章:逻辑回归、验证集选阈值与稳定的 Softmax;只用标准库。"""

from math import exp, log1p
from random import Random
from statistics import mean


def sigmoid(score):
    if score >= 0:
        return 1 / (1 + exp(-score))
    value = exp(score)
    return value / (1 + value)


def softmax(scores):
    largest = max(scores)
    values = [exp(score-largest) for score in scores]
    return [value/sum(values) for value in values]


def make_data(count, seed):
    rng = Random(seed)
    data = []
    for _ in range(count):
        pages, queue = rng.randint(5, 35), rng.randint(0, 10)
        features = [(pages-20)/10, (queue-5)/5]
        probability = sigmoid(0.9*features[0] + 0.8*features[1] - 0.8)
        label = int(rng.random() < probability)
        data.append((features, label))
    return data


def fit(data, steps=500, rate=0.2, regularization=0.05):
    weights, bias = [0.0, 0.0], 0.0
    for _ in range(steps):
        errors = [sigmoid(sum(w*x for w, x in zip(weights, features))+bias)-y
                  for features, y in data]
        gradients = [mean(e*row[0][j] for e, row in zip(errors, data))
                     + 2*regularization*weights[j] for j in range(2)]
        weights = [w-rate*g for w, g in zip(weights, gradients)]
        bias -= rate*mean(errors)
    return weights, bias


def probability(model, features):
    weights, bias = model
    return sigmoid(sum(w*x for w, x in zip(weights, features))+bias)


def binary_loss(score, label):
    # log(1+exp(score))-label*score 的稳定等价式。
    return max(score, 0) - label*score + log1p(exp(-abs(score)))


def evaluate(model, data, threshold):
    tp = fp = fn = tn = 0
    for features, label in data:
        predicted = probability(model, features) >= threshold
        tp += predicted and label == 1
        fp += predicted and label == 0
        fn += not predicted and label == 1
        tn += not predicted and label == 0
    precision = tp/(tp+fp) if tp+fp else 0.0
    recall = tp/(tp+fn) if tp+fn else 0.0
    return tp, fp, fn, tn, precision, recall, fp+3*fn


def main():
    train, validation, test = [make_data(n, seed) for n, seed in
                               [(120, 2026), (60, 2027), (60, 2028)]]
    model = fit(train)
    weights, bias = model
    loss = mean(binary_loss(sum(w*x for w, x in zip(weights, features))+bias, y)
                for features, y in train)
    print(f"训练交叉熵(不含正则项):{loss:.3f}")
    candidates = []
    for threshold in [0.25, 0.5, 0.75]:
        tp, fp, fn, tn, precision, recall, cost = evaluate(model, validation, threshold)
        candidates.append((cost, threshold))
        print(f"阈值={threshold:.2f}:验证 FP={fp},FN={fn},代价={cost}")
    _, threshold = min(candidates)
    print(f"固定阈值:{threshold:.2f}")
    tp, fp, fn, tn, precision, recall, cost = evaluate(model, test, threshold)
    print(f"测试 TP={tp},FP={fp},FN={fn},TN={tn}")
    print(f"测试精确率={precision:.3f},召回率={recall:.3f},代价={cost}")
    print("Softmax([2,1,0]):" + ", ".join(f"{p:.3f}" for p in softmax([2, 1, 0])))
    print(f"大分数的交叉熵:{binary_loss(1000, 0):.1f}")


if __name__ == "__main__":
    main()

输出为:

训练交叉熵(不含正则项):0.514
阈值=0.25:验证 FP=17,FN=6,代价=35
阈值=0.50:验证 FP=0,FN=22,代价=66
阈值=0.75:验证 FP=0,FN=24,代价=72
固定阈值:0.25
测试 TP=13,FP=20,FN=6,TN=21
测试精确率=0.394,召回率=0.684,代价=38
Softmax([2,1,0]):0.665, 0.245, 0.090
大分数的交叉熵:1000.0

选中的阈值减少了漏报,但精确率只有约 39%。这不是一个值得直接上线的报警器;它让我们看见“愿意多报一些”和“报警更可信”之间的取舍。数据量很小,换一次抽样,指标也可能明显改变。

处理不平衡还可以考虑类别权重或重采样,不过这些操作会改变训练目标或训练分布。重采样只应发生在训练部分,不能先复制少数类样本,再随机拆分到训练和验证两边。验证与测试也应尽量反映实际使用时的比例,才方便解释报警数量和代价。

让公式在计算机里也站得住

直接计算 e1000e^{1000} 会溢出,而 1−σ(1000)1-\sigma(1000) 在浮点数中可能已经变成零。接着对零取对数,就会报错。

代码按分数正负分别计算 Sigmoid,并用下面的等价形式计算交叉熵:

ℓ(s,y)=max⁡(s,0)−ys+log⁡(1+e−∣s∣) \ell(s,y)=\max(s,0)-ys+\log(1+e^{-|s|})

指数始终不大于 1。Python 的 log1p(a) 专门计算 log⁡(1+a)\log(1+a),当 aa 很小时也更稳。公式在纸面上相等,并不代表每种写法在有限精度计算中都同样可靠。

5. 类别超过两个怎么办

假设我们要把文档分成“文字”“图片”“混合”三类,每份只属于其中一类。模型为每类给一个分数 sks_k,用 Softmax 转成概率:

pk=esk∑j=1Kesj p_k=\frac{e^{s_k}}{\sum_{j=1}^K e^{s_j}}

KK 是类别数,所有概率非负且相加为 1。本例分数 [2,1,0][2,1,0] 对应约 [0.665,0.245,0.090][0.665,0.245,0.090]。它们不是三个独立的“是或否”,而是在互斥类别之间分配总概率。

标签用独热向量表示,正确类别位置为 1,其余为 0。交叉熵因此只留下正确类别的负对数概率:

ℓ=−∑k=1Kyklog⁡pk=−log⁡p正确类别 \ell=-\sum_{k=1}^K y_k\log p_k =-\log p_{\text{正确类别}}

实际实现会先把所有分数减去最大值。分子分母同时乘上相同的因子,概率不变,却避免了指数过大。试着给 [2,1,0] 全部加上 1000,稳定实现的结果应当相同。

若一份文档可以同时贴上“含图片”“含表格”“含手写内容”三个标签,那是多标签任务,通常为每个标签单独使用 Sigmoid。用 Softmax 强迫它们的概率和为 1,就把任务约束写错了。

6. 决策边界长什么样

对于二分类阈值 0.5,Sigmoid 的单调性告诉我们:

p≥0.5⟺wTx+b≥0 p\ge0.5 \quad\Longleftrightarrow\quad \boldsymbol w^\mathsf T\boldsymbol x+b\ge0

在两个特征的平面上,等号对应一条直线。换阈值会把右侧分数门槛改为 log⁡(t/(1−t))\log(t/(1-t));模型参数没变,分界线会平移。

现实中的类别未必能由直线分开。可以加入有意义的非线性特征,也可以采用后面的树模型和神经网络。先把概率、损失和决策分开理解,换模型时才不会把这些概念一起搅乱。

7. 自测与小实验

  1. 分数为零时,Sigmoid 输出多少?这代表模型“没有计算”吗?
  2. TP=8、FP=2、FN=4 时,精确率和召回率分别是多少?
  3. 为什么全预测多数类可能有很高准确率?
  4. 同一个模型从阈值 0.5 改到 0.25,权重改变了吗?
  5. 标签为 1 时,分数从 0 增加到 2,交叉熵会增大还是减小?
  6. 同时给所有 Softmax 分数加 100,会改变输出吗?
  7. 一篇文章可以同时属于多个主题,应该强制所有主题概率相加为 1 吗?
展开参考答案
  1. 为 0.5。它是计算得到的概率预测,不表示程序没工作。
  2. 精确率为 8/10=0.88/10=0.8,召回率为 8/12≈0.6678/12\approx0.667。
  3. 因为多数类贡献了绝大部分正确数,少数类可以全部被漏掉。
  4. 没有,改变的是从概率到行动的规则。
  5. 减小,正确类别的预测概率增大了。
  6. 不会,分子分母的共同倍数会约去;实现时仍应减最大值以保持稳定。
  7. 不应该。这是多标签任务,各标签可以同时成立。

选做:把漏报代价改为 1,只在验证集上重新选阈值。先记录你的预测,再运行代码。若你已经看过测试结果,这次尝试属于教学探索,不要把反复使用的测试集当成全新的独立评估。

下一章讨论模型评估与泛化:我们究竟怎样知道一个好分数值得相信?逻辑回归与多分类的常用实现,可查阅 scikit-learn 线性模型文档。