8 个任务,每个任务只有 9 个点。 单任务的平均准确率 0.7850,共享核权重之后 0.8238,提升 3.9 个百分点。 更关键的是稳:单任务逐个数在 0.700 到 0.867 之间跳,多任务全部落在 0.803 到 0.843 之间。 三个真实的坑:不对齐只有 0.6496;共享强度太小(0.001)掉回 0.7188; 32.7% 的分数是负数,不截断直接归一化会得到负概率(最小 -12.8816)。
上一节的参数是一串实数,最小二乘直接给闭式解,很省心。换成分类器,麻烦立刻多两层。 第一层:分类器输出的是概率,得非负、得加起来等于一。 而最小二乘只会拟合指示函数,完全不管这两个约束。 第二层:每个任务只有 9 个点时,连核基函数的系数都定不下来。 这一节用的办法是把 19.1 的共享结构原样搬过来: 每个任务的核权重拆成公共项加特有项,公共项由所有任务一起定。 但搬之前必须先做一件事——对齐。 这一件事做不做,差 17 个百分点。
一、先说说这个分类器
最小二乘概率分类器不用对数损失,用的是平方损失。 思路是:对每个类 c,用一个核基函数的线性组合去拟合「样本是不是属于 c」这个指示函数。
s_c(x) = sum_j beta_cj * K(x, x_j)
目标:minimize sum_i sum_c ( s_c(x_i) - 1(y_i = c) ) ^ 2
这个式子有闭式解,解一个线性方程组就行,不用迭代。 这是它比逻辑回归省事的地方,代价是它给的不是概率,只是「分数」。 要变成概率,最直接的做法是归一化:
p_c(x) = s_c(x) / sum_k s_k(x)
问题就在这里:最小二乘不保证 s_c 非负。
实测第一任务 300 个测试点、3 个类,共 900 个分数,其中 32.7% 是负数。
不截断直接归一化,最小概率会到负 12.88——这显然不是概率。
所以正确的做法是先截断再归一化,代码里那个 maximum(S, 1e-12) 不是可选项。
顺便看另一件事:三类分数之和本该等于 1,实测中位数是 0.9344。
差的那一点就是「拟合得不够准」的量,也说明这个分类器只是近似。
二、对齐:不做就白搭
这一节的任务是这样造的:三个类的相对布局完全相同,但每个任务的场景整体平移过, 还带一点旋转(正负 8.6 度)。 问题来了:核基函数是位置相关的。 同样一个「三个类摆成三角形」的结构,出现在 (-5, 0) 附近和出现在 (+3, -2) 附近, 核展开的系数完全不同。 不先对齐,各任务的权重根本没法共享。 对齐的办法很朴素:每个任务减掉自己样本的均值。 这个操作不需要标签,纯无监督,任何任务都做得到。 实测差得吓人:
| 做法 | 平均准确率 |
|---|---|
| 先对齐再共享 | 0.8238 |
| 不对齐直接共享 | 0.6496 |
| 差 17.4 个百分点,比多任务带来的全部收益还大好几倍。 | |
| 这件事的教训是:多任务学习里,比「怎么共享」更靠前的问题是「能不能共享」。 |
三、共享之后的两个效果
第一个效果是平均变高:
| 做法 | 平均准确率 |
|---|---|
| 单任务 | 0.7850 |
| 多任务 | 0.8238 |
| 3.9 个百分点。不算多,但要看第二个效果。 | |
| 第二个效果是变稳: |
| 任务 | 单任务 | 多任务 |
|---|---|---|
| 1 | 0.867 | 0.837 |
| 2 | 0.860 | 0.843 |
| 3 | 0.700 | 0.803 |
| 4 | 0.743 | 0.803 |
| 5 | 0.783 | 0.813 |
| 6 | 0.803 | 0.813 |
| 7 | 0.807 | 0.837 |
| 8 | 0.717 | 0.840 |
| 单任务那一列跨度 0.167(0.700 到 0.867),多任务只有 0.040(0.803 到 0.843)。 | ||
| 注意任务 1 和 2 反而略微下降(0.867 降到 0.837,0.860 降到 0.843)。 | ||
| 这是共享的代价:本来自己够用的任务,被公共项拉平了一点。 | ||
| 但从整体看,这笔买卖划算。 | ||
| 准确率这种指标,掉三个点的痛感远小于「有一个任务只有 0.700」的痛感——因为后者的下限太低, | ||
| 部署时最差的那个用户会直接抱怨。 |
四、共享强度的曲线
lam 扫一遍:
| 岭系数 | 多任务准确率 |
|---|---|
| 0.001 | 0.7188 |
| 0.01 | 0.7629 |
| 0.1 | 0.8071 |
| 0.3 | 0.8183 |
| 1 | 0.8238 |
| 3 | 0.8225 |
| 10 | 0.8200 |
| 两头低、中间高,而且左边掉得比右边狠。 | |
| lam 等于 0.001 时只有 0.7188,比单任务的 0.7850 还差。 | |
| 原因和 19.1 完全一样:私有部分没被压住,各任务自顾自地拟合,公共项被吸收进私有项里, | |
| 等于没共享。 | |
| 右边 3 和 10 只是略微回落(0.8225、0.8200),因为私有部分被压得太小, | |
| 各任务失去了自己的个性。 | |
| 但这里回落得不明显,说明在这份数据上「任务之间的差异」本来就不大。 |
五、动手 Demo:共享概率分类器:同一个结构搬到核基函数上,还得先对齐再共享
这段代码造 8 个只有 9 个点的分类任务,场景被平移并小角度旋转; 然后比较单任务分类器、对齐后共享的多任务分类器,最后看不对齐的后果和负分数问题。 重点看三处。第三段 0.7850 升到 0.8238 且逐个都更稳;第四段岭系数 0.001 时掉到 0.7188; 第五段不对齐只有 0.6496,以及第六段 32.7% 的分数为负。
# -*- coding: utf-8 -*-
import numpy as np
def rbf(A, B, h):
"""高斯核矩阵:A 的每一行和 B 的每一行两两之间。"""
D2 = ((A[:, None, :] - B[None, :, :]) ** 2).sum(-1)
return np.exp(-D2 / (2.0 * h * h))
def lspc_fit(X, y, C, h, lam=1e-2):
"""最小二乘概率分类器:每个类用核基函数拟合自己的指示函数。"""
K = rbf(X, X, h)
n = len(X)
B = np.zeros((C, n))
for c in range(C):
t = (y == c).astype(float)
B[c] = np.linalg.solve(K.T @ K + lam * np.eye(n), K.T @ t)
return B
def lspc_acc(B, Xtr, Xq, yq, h):
S = np.maximum(rbf(Xq, Xtr, h) @ B.T, 1e-12)
return float(np.mean(np.argmax(S, axis=1) == yq))
def cls_task_data(n_per=3, C=3, T=8, seed=3, shift=2.0, noise=0.75, rot=0.15):
"""T 个任务共用同一套类的相对布局,场景整体平移过、还带一点旋转。"""
rg = np.random.default_rng(seed)
base = np.array([[0.0, 0.0], [2.2, 0.0], [1.1, 1.9]])[:C]
Xs, Ys, Ok = [], [], []
for _ in range(T):
ang = rg.uniform(-rot, rot)
R = np.array([[np.cos(ang), -np.sin(ang)], [np.sin(ang), np.cos(ang)]])
off = rg.normal(0.0, shift, 2)
cen = base @ R.T + off
y = np.repeat(np.arange(C), n_per)
X = rg.normal(0.0, noise, (len(y), 2)) + cen[y]
idx = rg.permutation(len(y))
Xs.append(X[idx])
Ys.append(y[idx])
Ok.append(cen)
return Xs, Ys, Ok
def task_test(Ok, n=300, seed=77, noise=0.75):
rg = np.random.default_rng(seed)
out = []
for cen in Ok:
C = len(cen)
y = np.repeat(np.arange(C), n // C)
X = rg.normal(0.0, noise, (len(y), 2)) + cen[y]
out.append((X, y))
return out
def mt_lspc(Xs, Ys, C=3, h=0.5, lam=1.0, nz=24, seed=7, align=True):
"""核权重拆成公共项加特有项:公共项由所有任务一起定,特有项各自拟合。"""
if align:
mus = [X.mean(axis=0) for X in Xs]
Xa = [X - mu for X, mu in zip(Xs, mus)]
else:
mus = [np.zeros(2) for _ in Xs]
Xa = Xs
All = np.vstack(Xa)
rg = np.random.default_rng(seed)
Z = All[rg.choice(len(All), min(nz, len(All)), replace=False)]
Ks = [rbf(X, Z, h) for X in Xa]
A = sum(K.T @ K for K in Ks)
Ai = np.linalg.inv(A + 1e-6 * np.eye(len(Z)))
B0 = np.zeros((C, len(Z)))
for c in range(C):
b = sum(K.T @ (y == c).astype(float) for K, y in zip(Ks, Ys))
B0[c] = Ai @ b
Bt = []
for t in range(len(Xs)):
K = Ks[t]
Tt = np.zeros((len(Ys[t]), C))
Tt[np.arange(len(Ys[t])), Ys[t]] = 1.0
Bt.append(np.linalg.solve(K.T @ K + lam * np.eye(len(Z)), K.T @ (Tt - K @ B0.T)).T)
return B0, Bt, Z, mus
def mt_acc(B0, Bt, Z, mu, Xq, yq, h):
S = np.maximum(rbf(Xq - mu, Z, h) @ (B0 + Bt).T, 1e-12)
return float(np.mean(np.argmax(S, axis=1) == yq))
Xs, Ys, Ok = cls_task_data()
Ts = task_test(Ok)
T = len(Xs)
print('[1] 8 个任务,每个任务只有 9 个点')
print(' 每个任务 3 个类、每类 3 个样本;场景被平移过,还带一点旋转')
for t in range(3):
print(' 任务 %d 三个类的中心:%s' % (t + 1, ' '.join('(%+.2f,%+.2f)' % tuple(c) for c in Ok[t])))
print(' ...')
print('')
print('[2] 单任务:每个任务自己拟合一个概率分类器')
one = []
for t in range(T):
B = lspc_fit(Xs[t], Ys[t], 3, h=0.5)
one.append(lspc_acc(B, Xs[t], Ts[t][0], Ts[t][1], h=0.5))
print(' 平均准确率 %.4f' % np.mean(one))
print(' 逐个 ' + ' '.join('%.3f' % a for a in one))
print(' 训练集上是 1.000:核基函数的中心就是训练点,等于插值,看不出来')
print('')
print('[3] 多任务:先按样本均值对齐,再共享核权重的公共部分')
mt = []
B0, Bt, Z, mus = mt_lspc(Xs, Ys, h=0.5, lam=1.0)
for t in range(T):
mt.append(mt_acc(B0, Bt[t], Z, mus[t], Ts[t][0], Ts[t][1], h=0.5))
print(' 平均准确率 %.4f' % np.mean(mt))
print(' 逐个 ' + ' '.join('%.3f' % a for a in mt))
print(' 提升了 %.1f 个百分点' % (100 * (np.mean(mt) - np.mean(one))))
print('')
print('[4] 共享强度:太小等于没共享,太大把各任务的差异抹平')
print(' 岭系数 多任务准确率')
for lam in (1e-3, 1e-2, 0.1, 0.3, 1.0, 3.0, 10.0):
B0, Bt, Z, mus = mt_lspc(Xs, Ys, h=0.5, lam=lam)
a = np.mean([mt_acc(B0, Bt[t], Z, mus[t], Ts[t][0], Ts[t][1], h=0.5) for t in range(T)])
print(' %-9g %.4f' % (lam, a))
print('')
print('[5] 对齐这一步不能省')
for al in (True, False):
B0, Bt, Z, mus = mt_lspc(Xs, Ys, h=0.5, lam=1.0, align=al)
a = np.mean([mt_acc(B0, Bt[t], Z, mus[t], Ts[t][0], Ts[t][1], h=0.5) for t in range(T)])
print(' 对齐 %-5s 准确率 %.4f' % ('做' if al else '不做', a))
print(' 不对齐时各任务的场景叠不到一起,公共部分毫无意义')
print('')
print('[6] 分数会变成负数,归一化之前必须先截断')
B0, Bt, Z, mus = mt_lspc(Xs, Ys, h=0.5, lam=1.0)
S = rbf(Ts[0][0] - mus[0], Z, 0.5) @ (B0 + Bt[0]).T
print(' 第一任务 300 个测试点、3 个类,共 %d 个分数' % S.size)
print(' 其中为负的 %d 个,占 %.1f%%' % (int((S <= 0).sum()), 100.0 * (S <= 0).mean()))
print(' 分数范围 %.4f 到 %.4f' % (S.min(), S.max()))
print(' 三类之和本该是 1,实际中位数 %.4f' % np.median(S.sum(axis=1)))
print(' 不截断直接归一化,最小概率会到 %.4f' % (S / S.sum(axis=1, keepdims=True)).min())
实跑输出:
--------------------------------------------------------------------
共享概率分类器:同一个结构搬到核基函数上,还得先对齐再共享
--------------------------------------------------------------------
[1] 8 个任务,每个任务只有 9 个点
每个任务 3 个类、每类 3 个样本;场景被平移过,还带一点旋转
任务 1 三个类的中心:(-5.11,+0.84) (-2.93,+0.56) (-3.78,+2.59)
任务 2 三个类的中心:(+2.00,-1.77) (+4.18,-2.08) (+3.36,-0.05)
任务 3 三个类的中心:(+1.38,+0.98) (+3.58,+1.06) (+2.42,+2.92)
...
[2] 单任务:每个任务自己拟合一个概率分类器
平均准确率 0.7850
逐个 0.867 0.860 0.700 0.743 0.783 0.803 0.807 0.717
训练集上是 1.000:核基函数的中心就是训练点,等于插值,看不出来
[3] 多任务:先按样本均值对齐,再共享核权重的公共部分
平均准确率 0.8238
逐个 0.837 0.843 0.803 0.803 0.813 0.813 0.837 0.840
提升了 3.9 个百分点
[4] 共享强度:太小等于没共享,太大把各任务的差异抹平
岭系数 多任务准确率
0.001 0.7188
0.01 0.7629
0.1 0.8071
0.3 0.8183
1 0.8238
3 0.8225
10 0.8200
[5] 对齐这一步不能省
对齐 做 准确率 0.8238
对齐 不做 准确率 0.6496
不对齐时各任务的场景叠不到一起,公共部分毫无意义
[6] 分数会变成负数,归一化之前必须先截断
第一任务 300 个测试点、3 个类,共 900 个分数
其中为负的 294 个,占 32.7%
分数范围 -0.2996 到 1.3888
三类之和本该是 1,实际中位数 0.9344
不截断直接归一化,最小概率会到 -12.8816
第二段末尾那句「训练集上是 1.000」一定要留着。
核基函数的中心就取在训练点上,等于用 9 个基函数去插值 9 个点,当然完美。
这说明在这个模型上,训练集准确率是零信息量的指标——必须看测试集。
第三段那句「提升了 3.9 个百分点」后面,真正的亮点在逐个数字里。
单任务最差 0.700,多任务最差 0.803。
做产品的时候,你会更愿意要一个「全部在 0.80 以上」的方案,哪怕平均只高一点点。
第四段 lam 那一列,左边 0.001 掉到 0.7188,比单任务还低。
注意这不是 bug:共享结构没被激活时,多任务只是个绕远的实现方式,它还要多估一个公共项,
凭空多一层误差。
第五段 0.6496 是最该记住的数。
它比单任务的 0.7850 还低 13.5 个百分点——共享做错了,比不共享差得多。
而这里的「错」只是少做了一步减均值。
第六段 32.7% 和负 12.8816 是这一节最容易踩的坑。
最小二乘拟合的是 0 或 1 的指示函数,函数值当然可以跑到区间外。
任何拿最小二乘做分类的代码,归一化之前都必须先截断,否则概率会是负的。
代码里 mt_lspc 有个 align 开关,默认打开。
留这个开关就是为了让人能亲手关掉它,看看代价有多大——光看文字说「对齐很重要」是记不住的。
30 秒实验
- 把
cls_task_data的rot从 0.15 改成 0.8(约 46 度),重跑。 多任务的收益会明显缩水,甚至转负。 因为只剩平移能靠减均值消掉,旋转对齐不了。 真实场景里「能不能对齐」往往决定多任务能不能用。 - 把
cls_task_data的n_per从 3 改成 8,重跑。单任务会追上来。 多任务的价值随每任务样本量增加而递减,这是所有共享方法的共同规律。 - 把
mt_lspc里Bt全部置零,只留公共项,重跑。 实测只用公共项是 0.8217,加上特有项 0.8183——反而略低。 说明在这份数据上任务特有部分是多余的,共享结构已经够描述全部。 这也是个信号:特有项不是必须的,得让数据说话。
常见误区
- 「最小二乘概率分类器给的就是概率」。
它给的是分数,可以为负。
实测 32.7% 的分数是负数,不截断直接归一化,最小概率到负 12.8816。
必须先
maximum(S, 小正数)再归一化。 顺带说一句,三类分数之和的中位数是 0.9344 而不是 1,也说明它只是近似。 - 「共享之前不用做什么预处理」。 实测不对齐直接共享只有 0.6496,比单任务的 0.7850 还低 13.5 个百分点。 核基函数是位置相关的,场景不平移对齐,各任务的权重根本对不上。 这一步是无监督的,不需要额外标注,没有理由省。
- 「共享强度越大,越接近所有任务合成一个」。 lam 太大时私有部分被压没,各任务失去个性。 实测 lam 等于 10 时是 0.8200,比 1 的 0.8238 略低。 在这份数据上差异不大,因为任务本来就像; 换成本节实验里那种「任务差异大」的设定,lam 过大就会明显掉。
- 「多任务平均变好,说明每个任务都变好了」。 本节任务 1 从 0.867 降到 0.837,任务 2 从 0.860 降到 0.843。 共享是拿「本来够用的任务」去补贴「不够用的任务」。 平均涨、个别跌,是这套方法的正常形态。
自测三问(点开对答案)
1. 最小二乘概率分类器的分数为什么会是负数? —— 因为它用平方损失拟合 0 或 1 的指示函数,最小二乘只要求「拟合误差平方和最小」, 完全不约束函数值的范围。核基函数的系数有正有负,线性组合自然可能为负。 实测 32.7% 的分数为负,必须先截断再归一化。 2. 为什么共享之前一定要先做对齐? —— 因为核基函数是位置相关的:同样的类布局出现在不同位置,核展开的系数完全不同。 不对齐的话各任务的权重对不上,公共项毫无意义。 实测不对齐只有 0.6496,对齐后 0.8238,差 17.4 个百分点。对齐用减样本均值就行,不需要标签。 3. 多任务的平均准确率只高 3.9 个百分点,为什么还值得用? —— 因为更关键的是方差下降。 单任务逐个数在 0.700 到 0.867 之间跳,多任务全部落在 0.803 到 0.843 之间。 部署时最差的那个任务决定用户体验,把下限从 0.700 抬到 0.803 比抬平均更有价值。前两节的「多个任务」都是同一件事做多遍。 还有一种多任务:一次要预测好几个量,而且这几个量之间本来就相关。 比如一次预测 20 个指标,它们背后可能只有两三个共同的驱动因素。 下一节要做的,就是把这层结构显式写进模型里。
- ☐ (本节完,下一节 19.3 19.3)