两类的形状一模一样,只有比例变了:训练集正类占两成,测试集占五成。 不加权的模型训练准确率 0.9300、测试准确率 0.8275; 按类别加权后训练掉到 0.9200,测试升到 0.8925。 变的其实只是一条边界:从 x 等于 +0.5654 挪到 -0.0781,而两类样本的实际中点恰好是 -0.0781。
上一节的权重 w(x) 是输入的函数,得先估密度比。这一节的权重只跟标签有关,跟 x 无关。 代价小了,可利用的信息也少了。 情形是这样的:输入的分布在训练和测试时完全一样,y 给定 x 的规律也一样, 唯独「各类样本在总体里占多少」变了。 比如训练集里坏样本只占两成(因为做过欠采样),而线上坏样本其实占一半。 这类变化在实际中极其常见,因为很多人为了让训练集平衡, 会主动重采样——于是训练比例和真实比例天生就对不上。 这一节说的就是怎么在预测时把比例改回来。
一、只有比例变了
把假设写清楚,和上一节对照着看:
| 量 | 训练时 | 测试时 | 变没变 |
|---|---|---|---|
| p(x 给定 y) | 两个高斯 | 同样两个高斯 | 没变 |
| p(y) | 正类 0.2 | 正类 0.5 | 变了 |
| 注意这里变的是 p(y),不是 p(x)。 | |||
| 因为 p(x) 是两个 p(x 给定 y) 按 p(y) 混合出来的, | |||
| 所以 p(x) 其实也变了——但它的变化完全由 p(y) 造成,没有额外的信息。 | |||
| 这个区别决定了方法:如果变化全部来自 p(y),那么重要性权重就只依赖 y。 | |||
| 写成式子: |
w(x, y) = p_te(x, y) / p_tr(x, y)
= [ p_te(x 给定 y) * p_te(y) ] / [ p_tr(x 给定 y) * p_tr(y) ]
= p_te(y) / p_tr(y) (因为条件分布相同,前一项约掉)
x 从式子里消失了。 这是一个巨大的简化:不用估密度比,只要数一数两边的类别比例就行。 本节数据里,正类权重 0.5 除以 0.2 等于 2.500,负类权重 0.5 除以 0.8 等于 0.625。
二、加权之后,训练准确率反而降了
把这两个权重丢进加权最小二乘,结果有点反直觉:
| 做法 | 训练准确率 | 测试准确率 |
|---|---|---|
| 不加权 | 0.9300 | 0.8275 |
| 按类别加权 | 0.9200 | 0.8925 |
| 训练那一栏降了 0.01,测试那一栏涨了 0.065。 | ||
| 降是应该的:训练集的真实比例就是两成对八成,你按五成对五成去优化,在训练集上必然吃点亏。 | ||
| 涨也是应该的:测试集才是目标,权重就是照着测试集的比例设的。 | ||
| 这件事的教学价值在于:它把「训练集表现」和「目标分布上的表现」彻底分开了。 | ||
| 如果你拿训练集做模型选择,加权版永远选不上——可它才是上线后更好的那个。 | ||
| 顺带说一句,这也是为什么验证集必须按目标分布采样。 | ||
| 如果验证集沿用训练集的两成比例,你会得出「加权有害」的结论,然后选错模型。 |
三、变的其实只是一条边界
权重全为正类 2.5、负类 0.625,等价于把正类样本复制四份、负类复制一份。 样本复制不会改变两类各自的形状,只会把决策边界往多数类那边推。 实测:
| 量 | 数值 |
|---|---|
| 不加权时的边界 | x = +0.5654 |
| 加权后的边界 | x = -0.0781 |
| 两类样本的实际中心 | +1.1284 和 -1.2847 |
| 两个中心的中点 | -0.0781 |
| 加权后的边界和两类中心的中点,最后四位完全一样。 | |
| 这不是巧合。两类协方差相同时,贝叶斯最优边界就是中点; | |
| 最小二乘分类器在这种对称数据上恰好能把它找出来。 | |
| 不加权时的 +0.5654 偏了 0.64,偏的方向正是训练集里的少数类。 | |
| 换句话说:加权没有改变模型的形状,只是把它的偏置挪回了正中间。 | |
| 这也是为什么这一节只需要 ⭐⭐——机制简单,难的是想到「比例也会变」这件事。 |
四、先验相同时,它什么也不做
一个方法值不值得信,要看它在不需要它的时候会不会帮倒忙。 把训练先验也设成五成,重跑:
| 做法 | 测试准确率 | 边界 | |---:|---:| | 不加权 | 0.8925 | -0.0821 | | 加权 | 0.8925 | -0.0821 | 两行一个数字都不差。 因为这时正负两类的权重都是 2.5 和 0.625 的同一个倍数关系被归一化掉了,等价于全 1。 这是好性质:没有移位时它自动退化成原模型,不会引入额外风险。 顺便注意,这个 0.8925 和上一段加权后的 0.8925 完全一致。 因为两者算的是同一件事——都还原到了「先验各半」的情形。
五、把测试先验扫一遍
真正要看的是一张表:测试先验从 0.1 扫到 0.9。
| 测试先验 | 不加权 | 加权 | 加权后的边界 |
|---|---|---|---|
| 0.1 | 0.9350 | 0.9300 | +1.4751 |
| 0.2 | 0.9100 | 0.9100 | +0.5654 |
| 0.3 | 0.8725 | 0.8850 | +0.2428 |
| 0.4 | 0.8450 | 0.8725 | +0.0597 |
| 0.5 | 0.8275 | 0.8925 | -0.0781 |
| 0.6 | 0.8050 | 0.9050 | -0.2107 |
| 0.7 | 0.7850 | 0.8875 | -0.3752 |
| 0.8 | 0.7575 | 0.9150 | -0.6513 |
| 0.9 | 0.7325 | 0.9375 | -1.4089 |
| 不加权那一列从 0.9350 单调掉到 0.7325。 | |||
| 原因很直白:边界固定在 +0.5654,测试集里正类越多,被这条边界误判成正类以外的样本就越多。 | |||
| 这条边界本身没错,只是假设了「正类只占两成」。 | |||
| 加权那一列在 0.8850 到 0.9375 之间波动,明显更平。 | |||
| 边界一行行往左移:+1.4751、+0.5654、+0.2428,一直到 -1.4089,跟着测试集的真实比例走。 | |||
| 有两处要老实说。 | |||
| 第一,0.2 那一行两者完全相等(0.9100),因为训练先验就是 0.2,权重全是 1。 | |||
| 这是方法的定义决定的,不是巧合。 | |||
| 第二,0.1 那一行加权反而略差(0.9300 对 0.9350)。 | |||
| 因为测试先验离训练先验太远,负类权重涨到 9 倍,少数几个样本主导了拟合。 | |||
| 和上一节一样:移得越远,越没有本钱。 |
六、动手 Demo:类别平衡变化:形状不变、比例变了,只需要挪一下边界
这段代码造两个形状完全相同的高斯类,训练集让正类只占两成、测试集占五成; 然后按类别先验比加权,比训练准确率、测试准确率,再扫一遍测试先验从 0.1 到 0.9。 重点看三处。第二段训练准确率 0.9300 掉到 0.9200 而测试从 0.8275 升到 0.8925; 第三段加权后边界 -0.0781 与两类中心中点 -0.0781 完全一致;第五段不加权那一列的单调下滑。
# -*- coding: utf-8 -*-
import numpy as np
def wls(X, y, w=None):
"""加权最小二乘,返回斜率与截距。"""
n = len(X)
if w is None:
w = np.ones(n)
A = np.column_stack([np.ones(n), X])
W = np.diag(w / w.mean())
coef = np.linalg.solve(A.T @ W @ A + 1e-12 * np.eye(2), A.T @ W @ y)
return coef[1], coef[0]
def score(b, a, X):
"""最小二乘分类器的判别值,和 0 比得类别。"""
return a + b * X
def acc(b, a, X, y):
return float(np.mean((score(b, a, X) > 0.0).astype(float) * 2.0 - 1.0 == y))
def thresh(b, a):
"""判别值等于零的那个位置。"""
return -a / b
def label_weight(y, prior_tr, prior_te):
"""按类别给权:测试先验除以训练先验。"""
w = np.zeros(len(y))
for c, p_tr in ((1.0, prior_tr), (-1.0, 1.0 - prior_tr)):
p_te = prior_te if c > 0 else 1.0 - prior_te
w[y == c] = p_te / max(p_tr, 1e-9)
return w
def balance_data(n=400, seed=1, prior_tr=0.2, prior_te=0.5, sep=2.4, noise=1.0):
"""两类的高斯形状完全相同,只有先验比例不同。"""
rg = np.random.default_rng(seed)
def draw(n, prior):
nl = int(round(n * prior))
pos = rg.normal(sep / 2.0, noise, nl)
neg = rg.normal(-sep / 2.0, noise, n - nl)
x = np.concatenate([pos, neg])
y = np.concatenate([np.ones(nl), -np.ones(n - nl)])
idx = rg.permutation(n)
return x[idx], y[idx]
xtr, ytr = draw(n, prior_tr)
xte, yte = draw(n, prior_te)
return xtr, ytr, xte, yte
xtr, ytr, xte, yte = balance_data()
w = label_weight(ytr, 0.2, 0.5)
b0, a0 = wls(xtr, ytr)
b1, a1 = wls(xtr, ytr, w)
print('[1] 两类的形状一模一样,只有比例变了')
print(' 训练集正类占 %.0f%% 测试集正类占 %.0f%%' % (100 * (ytr > 0).mean(), 100 * (yte > 0).mean()))
print(' 正类样本的权重 %.3f 负类样本的权重 %.3f' % (w[ytr > 0][0], w[ytr < 0][0]))
print('')
print('[2] 同一个模型,两种称重方式')
print(' 不做任何处理 训练准确率 %.4f 测试准确率 %.4f' % (acc(b0, a0, xtr, ytr), acc(b0, a0, xte, yte)))
print(' 按类别加权 训练准确率 %.4f 测试准确率 %.4f' % (acc(b1, a1, xtr, ytr), acc(b1, a1, xte, yte)))
print(' 训练那一栏反而降了,测试那一栏才是我们要的')
print('')
print('[3] 变的其实只是一条边界')
print(' 不加权的边界 x = %+.4f' % thresh(b0, a0))
print(' 加权后的边界 x = %+.4f' % thresh(b1, a1))
print(' 两类样本的实际中心 %+.4f 和 %+.4f,中点 %+.4f'
% (xtr[ytr > 0].mean(), xtr[ytr < 0].mean(), (xtr[ytr > 0].mean() + xtr[ytr < 0].mean()) / 2.0))
print(' 加权把边界推到了中点,不偏袒任何一类')
print('')
print('[4] 训练先验和测试先验一样时,加权什么也不做')
xa, ya, xb, yb = balance_data(prior_tr=0.5, prior_te=0.5)
wa = label_weight(ya, 0.5, 0.5)
u0, v0 = wls(xa, ya)
u1, v1 = wls(xa, ya, wa)
print(' 不加权 测试 %.4f 边界 %+.4f' % (acc(u0, v0, xb, yb), thresh(u0, v0)))
print(' 加权 测试 %.4f 边界 %+.4f' % (acc(u1, v1, xb, yb), thresh(u1, v1)))
print(' 两组权重全是同一个数,等于没加权')
print('')
print('[5] 测试先验从 0.1 扫到 0.9')
print(' 测试先验 不加权 加权 加权后的边界')
for pte in (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9):
xa, ya, xb, yb = balance_data(prior_tr=0.2, prior_te=pte)
wa = label_weight(ya, 0.2, pte)
u0, v0 = wls(xa, ya)
u1, v1 = wls(xa, ya, wa)
print(' %.1f %.4f %.4f %+.4f'
% (pte, acc(u0, v0, xb, yb), acc(u1, v1, xb, yb), thresh(u1, v1)))
实跑输出:
--------------------------------------------------------------------
类别平衡变化:形状不变、比例变了,只需要挪一下边界
--------------------------------------------------------------------
[1] 两类的形状一模一样,只有比例变了
训练集正类占 20% 测试集正类占 50%
正类样本的权重 2.500 负类样本的权重 0.625
[2] 同一个模型,两种称重方式
不做任何处理 训练准确率 0.9300 测试准确率 0.8275
按类别加权 训练准确率 0.9200 测试准确率 0.8925
训练那一栏反而降了,测试那一栏才是我们要的
[3] 变的其实只是一条边界
不加权的边界 x = +0.5654
加权后的边界 x = -0.0781
两类样本的实际中心 +1.1284 和 -1.2847,中点 -0.0781
加权把边界推到了中点,不偏袒任何一类
[4] 训练先验和测试先验一样时,加权什么也不做
不加权 测试 0.8925 边界 -0.0821
加权 测试 0.8925 边界 -0.0821
两组权重全是同一个数,等于没加权
[5] 测试先验从 0.1 扫到 0.9
测试先验 不加权 加权 加权后的边界
0.1 0.9350 0.9300 +1.4751
0.2 0.9100 0.9100 +0.5654
0.3 0.8725 0.8850 +0.2428
0.4 0.8450 0.8725 +0.0597
0.5 0.8275 0.8925 -0.0781
0.6 0.8050 0.9050 -0.2107
0.7 0.7850 0.8875 -0.3752
0.8 0.7575 0.9150 -0.6513
0.9 0.7325 0.9375 -1.4089
第一段两行权重 2.500 和 0.625,比值正好是 4。 这等于把正类样本复制四份——因为 0.5 除以 0.2 是 2.5,0.5 除以 0.8 是 0.625, 再除以两者公因子,就是 4 比 1。 第二段那两行要一起读。 训练准确率 0.9300 掉到 0.9200,测试 0.8275 升到 0.8925。 如果只报训练那一栏,加权是个坏主意;只报测试那一栏,加权是个好主意。 报哪一个,取决于你打算用哪个分布去评估。 第三段是本篇最漂亮的一处。 两类中心 +1.1284 和 -1.2847,中点算出来 -0.0781;加权后的边界也印出来 -0.0781。 这两个数不是凑的,是同一个量的两种算法。 不加权的 +0.5654 偏离中点 0.64,偏的方向就是训练集里的少数类。 第四段那个「一个数字都不差」的对照组不能省。 它证明了这个方法在没有移位时是恒等变换,不会平白引入风险。 很多所谓的「改进方法」恰恰缺这个性质——不需要它的时候它也在捣乱。 第五段 0.1 和 0.2 两行是诚实的边界。 0.2 行两者相等,是因为训练先验就是 0.2; 0.1 行加权略差(0.9300 对 0.9350),是因为外推得太远。 报方法效果时把这两行留着,比只报最好看的那几行可信。
30 秒实验
- 把
balance_data的sep从 2.4 改成 1.2,让两类重叠得更厉害,重跑。 看加权带来的提升从 0.065 缩到多少。 两类越难分,边界位置的影响越小——反正怎么划都错一堆。 - 把
balance_data的prior_tr从 0.2 改成 0.05,重跑。 少数类只有 20 个样本,加权到 10 倍之后,边界会开始抖。 这就是类别不平衡问题的真面目:不是权重算错了,是少数类的样本根本不够。 - 把
label_weight里的prior_te故意传错(比如真实是 0.5,你按 0.3 算),重跑。 看测试准确率掉到多少。 这一节的权重完全依赖「测试先验是多少」这个外部知识——估错了,方法就失效。 实战里这个数往往得靠业务经验或者线上统计拿到。
常见误区
- 「训练准确率降了,说明加权是错的」。 训练集的真实比例是两成对八成,按五成对五成优化当然在训练集上吃亏。 实测 0.9300 掉到 0.9200,同时测试从 0.8275 升到 0.8925。 该看哪一栏,取决于部署时面对的是哪个分布。
- 「类别平衡变化也要估密度比」。 不用。 因为条件分布没变,密度比里的 p(x 给定 y) 上下约掉了, 只剩 p_te(y) 除以 p_tr(y)——一个只跟标签有关的常数。 这一节的权重是一张两行的查找表,不是一个函数。
- 「加权会改变模型的形状」。 实测加权前后的边界从 +0.5654 挪到 -0.0781,但两类中心没动,加权后的边界正好落在中点。 样本复制只挪偏置,不改形状。 如果两类的协方差本来就不同,光靠加权是补不回来的——那得换模型。
- 「测试先验可以随便估」。 整套方法的权重完全由 p_te(y) 决定。 估成 0.1 而实际是 0.5,边界会停在 +1.4751 附近,比不加权的 +0.5654 还偏。 这个数必须从业务或线上统计里拿准,不能拍脑袋。
自测三问(点开对答案)
1. 为什么这一节的权重里没有 x? —— 因为假设只有 p(y) 变了、p(x 给定 y) 没变。 把 p_te(x,y)/p_tr(x,y) 展开成条件分布乘先验,条件分布那一项上下相同直接约掉, 只剩 p_te(y)/p_tr(y)。所以权重是一张按类别查的表。 2. 加权之后训练准确率降了,为什么还要用它? —— 因为训练集的比例(两成对八成)不是部署时要面对的比例(五成对五成)。 加权是照着目标比例优化的,所以在训练集上必然吃亏。 实测训练 0.9300 降到 0.9200,测试 0.8275 升到 0.8925。 关键前提是验证集也得按目标分布采样,否则会选错模型。 3. 加权后的边界 -0.0781 为什么恰好等于两类中心的中点? —— 因为两类协方差相同,贝叶斯最优边界就是两类中心的中点, 而最小二乘分类器在这种对称数据上恰好能找出它。 不加权时的 +0.5654 偏了 0.64,偏向训练集里的多数类(负类)。 加权等价于把正类样本复制四份,把边界推回正中间。第 18 章处理的是「一个任务,两个分布」。 还有一种情形:根本没有「一个任务」——手上同时有好几个相关但不同的任务,每个都有自己的数据, 而且都不多。 下一章要问的是:这些任务能不能互相借力。
- ☐ (本章完,下一节 19.1 19.1)