分类:从逻辑回归到多分类
“还要等多久?”和“会不会超时?”问的是同一台打印机,却需要不同形式的答案。前一章预测一个连续数值;这一章预测一个事件是否发生。
最直接的想法,是把“超时”记成 1,“没有超时”记成 0,然后继续拟合直线。但直线可能输出 −0.3 或 1.4。拿它们当概率,就说不通了。我们需要一个把任意实数压进 0~1 的函数。
1. 给直线的输出加一道弯
先计算一个分数:
再经过 Sigmoid 函数:
时,; 很大时接近 1,很小时接近 0。这里 是自然对数的底,与第五章的对数相对应。我们把 解释为模型预测的超时概率。
图 1:分数变动相同幅度,概率不一定变动相同幅度。在两端,曲线会逐渐变平。
这就是逻辑回归。名字里有“回归”,常见用途却是分类。它先预测概率,再根据规则给出类别。
把公式稍作整理,还能得到:
叫作胜算,例如 时胜算为 3,表示发生与不发生的概率之比是 3∶1。逻辑回归对这个比值的对数建立线性模型,而不是要求概率本身沿直线变化。
2. 用交叉熵训练概率
第五章已经认识了二分类交叉熵。对于标签 :
如果实际超时,模型却给了极小概率,损失就很大。训练会推动模型减少这种“很有把握却猜错”的情况。
结合 Sigmoid,损失对分数的导数有一个简洁结果:
可以这样核对:,而交叉熵对 的导数是 ;相乘后分母正好消去。于是:
这里 是平均交叉熵, 是第 条记录的第 个特征。它与线性回归的梯度很相似:都有“预测与实际的差,再乘输入”。损失和输出函数不同,最终得到的差恰好也很简洁。
本章代码再加上 ,所以权重梯度多出 。这有助于约束有限样本中的权重;若数据恰好能被完美分开,无正则项的逻辑回归甚至可能不断增大权重,趋近更小损失却没有有限的最优权重。
3. 概率和行动,中间还隔着一个阈值
模型给出超时概率 0.4,要不要提前提醒用户?答案取决于误报和漏报各有多大代价。
规定 时报警, 就是分类阈值。常见的 0.5 只是一个选择。如果漏报更麻烦,可以降低阈值,让模型更愿意报警。
| 实际情况 | 报警 | 不报警 |
|---|---|---|
| 超时 | 真正例 TP | 假负例 FN,漏报 |
| 未超时 | 假正例 FP,误报 | 真负例 TN |
这里“正例”指超时,与好坏无关。我们关心的两个比例是:
精确率回答“报警中有多少真的超时”;召回率回答“真实超时中找出了多少”。一个看预测出来的集合,一个看实际发生的集合。分母为零时,指标没有通常的比例解释;本章代码约定返回 0,便于展示,但分析时仍应说明没有预测正例或没有实际正例。
准确率为 。若一千次任务只有十次超时,永远说“不超时”也有 99% 准确率,却一个超时都没找出。这就是类别不平衡为什么会使单一准确率具有迷惑性。
本次教学实验把一次误报代价定为 1,一次漏报代价定为 3:
这个代价来自我们对任务的设定,不是算法自动发现的事实。阈值在验证集上选择,测试集只负责评估选好后的规则。
图 2:三种候选阈值都使用同一组模型参数。改变阈值没有重新训练模型。
如果概率估计完全可靠,且只有这两种固定误判代价,可以直接比较期望代价:报警花费 ,不报警花费 ,所以 时报警。现实中的概率可能不够准确,代价也可能变化,因此还需要验证数据和实际反馈。
一个模型给很多任务打出 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%。这不是一个值得直接上线的报警器;它让我们看见“愿意多报一些”和“报警更可信”之间的取舍。数据量很小,换一次抽样,指标也可能明显改变。
处理不平衡还可以考虑类别权重或重采样,不过这些操作会改变训练目标或训练分布。重采样只应发生在训练部分,不能先复制少数类样本,再随机拆分到训练和验证两边。验证与测试也应尽量反映实际使用时的比例,才方便解释报警数量和代价。
让公式在计算机里也站得住
直接计算 会溢出,而 在浮点数中可能已经变成零。接着对零取对数,就会报错。
代码按分数正负分别计算 Sigmoid,并用下面的等价形式计算交叉熵:
指数始终不大于 1。Python 的 log1p(a) 专门计算 ,当 很小时也更稳。公式在纸面上相等,并不代表每种写法在有限精度计算中都同样可靠。
5. 类别超过两个怎么办
假设我们要把文档分成“文字”“图片”“混合”三类,每份只属于其中一类。模型为每类给一个分数 ,用 Softmax 转成概率:
是类别数,所有概率非负且相加为 1。本例分数 对应约 。它们不是三个独立的“是或否”,而是在互斥类别之间分配总概率。
标签用独热向量表示,正确类别位置为 1,其余为 0。交叉熵因此只留下正确类别的负对数概率:
实际实现会先把所有分数减去最大值。分子分母同时乘上相同的因子,概率不变,却避免了指数过大。试着给 [2,1,0] 全部加上 1000,稳定实现的结果应当相同。
若一份文档可以同时贴上“含图片”“含表格”“含手写内容”三个标签,那是多标签任务,通常为每个标签单独使用 Sigmoid。用 Softmax 强迫它们的概率和为 1,就把任务约束写错了。
6. 决策边界长什么样
对于二分类阈值 0.5,Sigmoid 的单调性告诉我们:
在两个特征的平面上,等号对应一条直线。换阈值会把右侧分数门槛改为 ;模型参数没变,分界线会平移。
现实中的类别未必能由直线分开。可以加入有意义的非线性特征,也可以采用后面的树模型和神经网络。先把概率、损失和决策分开理解,换模型时才不会把这些概念一起搅乱。
7. 自测与小实验
- 分数为零时,Sigmoid 输出多少?这代表模型“没有计算”吗?
- TP=8、FP=2、FN=4 时,精确率和召回率分别是多少?
- 为什么全预测多数类可能有很高准确率?
- 同一个模型从阈值 0.5 改到 0.25,权重改变了吗?
- 标签为 1 时,分数从 0 增加到 2,交叉熵会增大还是减小?
- 同时给所有 Softmax 分数加 100,会改变输出吗?
- 一篇文章可以同时属于多个主题,应该强制所有主题概率相加为 1 吗?
展开参考答案
- 为 0.5。它是计算得到的概率预测,不表示程序没工作。
- 精确率为 ,召回率为 。
- 因为多数类贡献了绝大部分正确数,少数类可以全部被漏掉。
- 没有,改变的是从概率到行动的规则。
- 减小,正确类别的预测概率增大了。
- 不会,分子分母的共同倍数会约去;实现时仍应减最大值以保持稳定。
- 不应该。这是多标签任务,各标签可以同时成立。
选做:把漏报代价改为 1,只在验证集上重新选阈值。先记录你的预测,再运行代码。若你已经看过测试结果,这次尝试属于教学探索,不要把反复使用的测试集当成全新的独立评估。
下一章讨论模型评估与泛化:我们究竟怎样知道一个好分数值得相信?逻辑回归与多分类的常用实现,可查阅 scikit-learn 线性模型文档。