🤖 第19章 多任务学习
📄 一句话概括

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 秒实验

  1. 把 cls_task_data 的 rot 从 0.15 改成 0.8(约 46 度),重跑。 多任务的收益会明显缩水,甚至转负。 因为只剩平移能靠减均值消掉,旋转对齐不了。 真实场景里「能不能对齐」往往决定多任务能不能用。
  2. 把 cls_task_data 的 n_per 从 3 改成 8,重跑。单任务会追上来。 多任务的价值随每任务样本量增加而递减,这是所有共享方法的共同规律。
  3. 把 mt_lspc 里 Bt 全部置零,只留公共项,重跑。 实测只用公共项是 0.8217,加上特有项 0.8183——反而略低。 说明在这份数据上任务特有部分是多余的,共享结构已经够描述全部。 这也是个信号:特有项不是必须的,得让数据说话。

常见误区

  1. 「最小二乘概率分类器给的就是概率」。 它给的是分数,可以为负。 实测 32.7% 的分数是负数,不截断直接归一化,最小概率到负 12.8816。 必须先 maximum(S, 小正数) 再归一化。 顺带说一句,三类分数之和的中位数是 0.9344 而不是 1,也说明它只是近似。
  2. 「共享之前不用做什么预处理」。 实测不对齐直接共享只有 0.6496,比单任务的 0.7850 还低 13.5 个百分点。 核基函数是位置相关的,场景不平移对齐,各任务的权重根本对不上。 这一步是无监督的,不需要额外标注,没有理由省。
  3. 「共享强度越大,越接近所有任务合成一个」。 lam 太大时私有部分被压没,各任务失去个性。 实测 lam 等于 10 时是 0.8200,比 1 的 0.8238 略低。 在这份数据上差异不大,因为任务本来就像; 换成本节实验里那种「任务差异大」的设定,lam 过大就会明显掉。
  4. 「多任务平均变好,说明每个任务都变好了」。 本节任务 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 个指标,它们背后可能只有两三个共同的驱动因素。 下一节要做的,就是把这层结构显式写进模型里。

相关笔记