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

8 个任务,每个只有 12 个样本,输入 20 维——单任务连方程都解不稳。 各自独立拟合,平均测试误差 0.6373;把参数拆成公共项加特有项后,0.3533。 任务数从 1 加到 12,改善从 1.00 倍一路涨到 2.07 倍,而选到的共享强度从 0.01 单调升到 3。 任务越少,越只能弱共享——这是整套方法最需要手感的一个旋钮。 8 个任务逐个看,全部变好,最差的那个从 0.9675 降到 0.3963。

📝 本节和前面几节的关系

第 18 章处理的是「一个任务,两个分布」。这一章换一个方向: 手上根本没有「一个任务」这回事,而是好几个相关但不同的任务,每个都有自己的数据, 每个都不多。 这种局面比想象的常见。 同一个语音识别系统要给几十个人用,每人只有几分钟录音; 同一套销量预测要给几百家门店做,每家只有几十天历史;同一个手写识别要给几十个用户做适配。 任务之间结构相似,但各有各的脾气。 能不能互相借力?能,前提是借的方式要对。 这一节用的办法是把每个任务的参数拆成两半:一半大家共有,一半各自私有。 共有的那一半从所有任务的数据里一起估,等于凭空多出几倍样本。

一、问题长什么样

先把局面摊开。

量 数值 意味着
任务数 8 不算多,也不算少
每个任务的样本 12 远小于维数
输入维数 20 12 个方程解 20 个未知数
噪声标准差 0.30 预测误差的下限是 0.09
任务间偏离幅度 0.15 很接近,但不完全一样
每个任务的参数都是「一个公共方向 + 一点各自偏离」。
公共方向模长 1,偏离的模长约 0.67(20 个分量各 0.15)。
也就是说,任务之间的相似度高于一半,但没有到「完全一样」。
12 个样本估 20 个系数,属于欠定问题。
直接解正规方程会得到无数个解里的某一个,取决于数值库怎么挑,
所以必须加岭项——这就是单任务那一栏的岭系数为什么必须扫一遍。

二、把参数拆成两半

多任务的做法是假设每个任务的参数可以写成两部分之和:

w_t = w0 + b_t

w0 是公共项,所有任务共有;b_t 是任务 t 的特有项。 要优化的目标就变成:

minimize   sum_t sum_i ( y_ti - (w0 + b_t)' x_ti ) ^ 2
           + lam * sum_t | b_t | ^ 2

注意惩罚项只加在 b_t 上,不加在 w0 上。 这个选择是全部设计的关键:w0 可以自由地大,因为它有所有任务的数据支撑; b_t 必须小,因为每个任务只有 12 个样本,撑不起一个大的私有参数。 反过来想就明白了。如果连 w0 也惩罚,模型会退化成「各任务独立加岭回归」; 如果连 b_t 也不惩罚,w0 和 b_t 可以互相抵消——w0 加 c、b_t 减 c,得到同样的总参数,解不唯一。 求解用交替最小化:固定 w0 各任务独立解 b_t,固定 b_t 再解 w0。 这是一个凸二次问题,块坐标下降保证收敛。 实测交替 300 次的结果和一次闭式解差 3.8 乘 10 的负 8 次方以内,可以放心用交替。

三、它到底省下了什么

跑出来是这样:

做法 最好的岭系数 平均测试误差
单任务各自拟合 1.0 0.6373
多任务共享 3.0 0.3533
改善 1.80 倍。
注意误差下限是 0.09,所以多任务那 0.3533 里还有相当一部分是没法消掉的噪声。
从 0.6373 到 0.3533,消掉的主要是「用 12 个样本估 20 个系数」带来的方差。
逐个任务看,8 个全部变好:
任务 单任务 多任务
1 0.4701 0.3726
2 0.3119 0.2765
3 0.6454 0.3739
4 0.7227 0.3189
5 0.7456 0.5017
6 0.7558 0.3884
7 0.9675 0.3963
8 0.4791 0.1980
最惨的第 7 个任务从 0.9675 降到 0.3963,降了将近六成。
它的样本恰好落得不好,独立拟合几乎全错;借了公共项之后被拉回正轨。
估出来的公共项和真公共项夹角 22.7 度。
不是零,因为 8 个任务的私有偏离本身就会污染公共项的估计。

四、任务越多越好,但要看你怎么调

把任务数从 1 扫到 12:

任务数 单任务 多任务 改善 选到的岭系数
1 0.4316 0.4316 1.00 0.01
2 0.3766 0.3634 1.04 0.01
3 0.4559 0.3609 1.26 0.1
5 0.5791 0.3712 1.56 3
8 0.6373 0.3533 1.80 3
12 0.7664 0.3711 2.07 3
三列数字,三件事。
第一,单任务那一列随任务数变大而变差(0.4316 涨到 0.7664)。
这不是任务变难了,而是任务数越多,随机性越大,「平均」里掺进来的差样本越多。
第二,多任务那一列基本不动(0.35 到 0.37)。
因为它估的是公共项,任务越多,公共项估得越准,误差就稳住了。
第三,也是最该记的一列:选到的岭系数随任务数从 0.01 涨到 3。
任务只有一两个的时候,公共项几乎估不出来,
这时强共享是灾难——实测 T 等于 2 时如果把 lam 硬设成 0.3,误差会从 0.3634 恶化到 1.3929。
道理不复杂:共享的前提是「大家真的像」。
只有两个任务时,你没法判断它们像不像,公共项只是两组数据的折中,强行让各任务靠近它,
等于用一个没根据的中心去拉偏。

五、动手 Demo:共享最小二乘:把参数拆成公共项加特有项,每个任务只管自己那一小块

这段代码造 8 个任务,每个 12 个样本、20 维,任务之间共用一个方向; 然后分别做单任务岭回归和多任务共享,最后扫一遍任务数和逐个任务看效果。 重点看三处。第二段单任务 0.6373 和第三段多任务 0.3533; 第四段改善从 1.00 倍涨到 2.07 倍同时岭系数从 0.01 升到 3;第五段第 7 个任务从 0.9675 降到 0.3963。

# -*- coding: utf-8 -*-
import numpy as np


def mt_data(n_per=12, d=20, T=8, seed=1, noise=0.3, own=0.15):
    """T 个任务共用一个方向,各自再带一点偏离。每个任务的样本都很少。"""
    rg = np.random.default_rng(seed)
    w0 = rg.normal(0.0, 1.0, d)
    w0 = w0 / np.linalg.norm(w0)
    Xs, Ys, Ws = [], [], []
    for _ in range(T):
        w = w0 + rg.normal(0.0, own, d)
        X = rg.normal(0.0, 1.0, (n_per, d))
        y = X @ w + rg.normal(0.0, noise, n_per)
        Xs.append(X)
        Ys.append(y)
        Ws.append(w)
    return Xs, Ys, Ws, w0


def ridge(X, y, lam=1e-3):
    """带岭项的普通最小二乘,单任务用。"""
    d = X.shape[1]
    return np.linalg.solve(X.T @ X + lam * np.eye(d), X.T @ y)


def mt_fit(Xs, Ys, lam=1.0, iters=200):
    """每个任务的参数 = 公共部分 + 特有部分,只惩罚特有部分。"""
    d = Xs[0].shape[1]
    eye = np.eye(d)
    XX = [X.T @ X for X in Xs]
    XY = [X.T @ y for X, y in zip(Xs, Ys)]
    w0 = np.zeros(d)
    W = [np.zeros(d) for _ in Xs]
    for _ in range(iters):
        for t in range(len(Xs)):
            W[t] = np.linalg.solve(XX[t] + lam * eye, XY[t] - XX[t] @ w0)
        A = np.zeros((d, d))
        b = np.zeros(d)
        for t in range(len(Xs)):
            A += XX[t] + 1e-6 * eye
            b += XY[t] - XX[t] @ W[t]
        w0 = np.linalg.solve(A, b)
    return w0, W


def test_mse(w, wt, seed=99, n=400, noise=0.3):
    """拿参数去和真参数比:预测误差减去噪声,剩下的就是参数差。"""
    rg = np.random.default_rng(seed)
    X = rg.normal(0.0, 1.0, (n, len(w)))
    y = X @ wt + rg.normal(0.0, noise, n)
    return float(np.mean((X @ w - y) ** 2))


def avg_single(Xs, Ys, Ws, lam):
    return float(np.mean([test_mse(ridge(Xs[t], Ys[t], lam), Ws[t]) for t in range(len(Xs))]))


def avg_mt(Xs, Ys, Ws, lam):
    w0, W = mt_fit(Xs, Ys, lam=lam)
    return float(np.mean([test_mse(w0 + W[t], Ws[t]) for t in range(len(Xs))]))


Xs, Ys, Ws, w0 = mt_data()

print('[1] 8 个任务,每个只有 12 个样本,输入 20 维')
print('  任务数 %d   每任务样本 %d   维数 %d' % (len(Xs), len(Xs[0]), Xs[0].shape[1]))
print('  每个任务的参数 = 一个公共方向 + 一点各自偏离(偏离幅度 0.15)')
print('  噪声标准差 0.30,也就是预测误差的下限是 0.09')

print('')
print('[2] 单任务:各自解各自的,12 个样本估 20 个系数')
print('  岭系数   平均测试误差')
for lam in (1e-3, 0.1, 1.0, 10.0):
    print('  %-9g %.4f' % (lam, avg_single(Xs, Ys, Ws, lam)))

print('')
print('[3] 多任务:把参数拆成公共项加特有项')
print('  岭系数   平均测试误差')
for lam in (0.01, 0.1, 1.0, 3.0, 10.0):
    print('  %-9g %.4f' % (lam, avg_mt(Xs, Ys, Ws, lam)))

best_s = min(avg_single(Xs, Ys, Ws, l) for l in (1e-3, 0.1, 1.0, 10.0))
best_m = min(avg_mt(Xs, Ys, Ws, l) for l in (0.01, 0.1, 1.0, 3.0, 10.0))
print('  最好的单任务 %.4f,最好的多任务 %.4f,改善 %.2f 倍' % (best_s, best_m, best_s / best_m))

print('')
print('[4] 任务数越多,公共项估得越准')
print('  任务数  单任务   多任务   改善   多任务选到的岭系数')
for T in (1, 2, 3, 5, 8, 12):
    Xa, Ya, Wa, _ = mt_data(T=T)
    bs = min(avg_single(Xa, Ya, Wa, l) for l in (1e-3, 0.1, 1.0, 10.0))
    cand = [(avg_mt(Xa, Ya, Wa, l), l) for l in (0.01, 0.03, 0.1, 0.3, 1.0, 3.0, 10.0)]
    bm, lm = min(cand)
    print('  %2d     %.4f   %.4f   %.2f 倍   %g' % (T, bs, bm, bs / bm, lm))

print('')
print('[5] 逐个任务看,是不是每个都变好了')
w0m, Wm = mt_fit(Xs, Ys, lam=3.0)
print('  任务  单任务   多任务')
for t in range(len(Xs)):
    e1 = test_mse(ridge(Xs[t], Ys[t], 1.0), Ws[t])
    e2 = test_mse(w0m + Wm[t], Ws[t])
    print('   %d    %.4f   %.4f' % (t + 1, e1, e2))
c = abs(float(w0m @ w0)) / (np.linalg.norm(w0m) * np.linalg.norm(w0))
print('  估出的公共项与真公共项的夹角 %.1f 度' % np.degrees(np.arccos(min(c, 1.0))))

实跑输出:

--------------------------------------------------------------------
共享最小二乘:把参数拆成公共项加特有项,每个任务只管自己那一小块
--------------------------------------------------------------------
[1] 8 个任务,每个只有 12 个样本,输入 20 维
  任务数 8   每任务样本 12   维数 20
  每个任务的参数 = 一个公共方向 + 一点各自偏离(偏离幅度 0.15)
  噪声标准差 0.30,也就是预测误差的下限是 0.09

[2] 单任务:各自解各自的,12 个样本估 20 个系数
  岭系数   平均测试误差
  0.001     0.6970
  0.1       0.6839
  1         0.6373
  10        0.6897

[3] 多任务:把参数拆成公共项加特有项
  岭系数   平均测试误差
  0.01      0.6495
  0.1       0.4805
  1         0.3812
  3         0.3533
  10        0.3605
  最好的单任务 0.6373,最好的多任务 0.3533,改善 1.80 倍

[4] 任务数越多,公共项估得越准
  任务数  单任务   多任务   改善   多任务选到的岭系数
   1     0.4316   0.4316   1.00 倍   0.01
   2     0.3766   0.3634   1.04 倍   0.01
   3     0.4559   0.3609   1.26 倍   0.1
   5     0.5791   0.3712   1.56 倍   3
   8     0.6373   0.3533   1.80 倍   3
  12     0.7664   0.3711   2.07 倍   3

[5] 逐个任务看,是不是每个都变好了
  任务  单任务   多任务
   1    0.4701   0.3726
   2    0.3119   0.2765
   3    0.6454   0.3739
   4    0.7227   0.3189
   5    0.7456   0.5017
   6    0.7558   0.3884
   7    0.9675   0.3963
   8    0.4791   0.1980
  估出的公共项与真公共项的夹角 22.7 度

第一段那句「预测误差的下限是 0.09」要先立住。 噪声标准差 0.30,方差就是 0.09——无论参数估得多准,误差也不可能低于它。 所以后面所有数字都要和 0.09 比,而不是和 0 比。 第二段单任务那一列有个坑:lam 从 0.001 加到 10,误差先降后升(0.6970、0.6839、0.6373、 0.6897)。 lam 太小解不稳,太大把真实信号也压掉,中间那点才是甜的。 这个「甜点」在真实数据上只能靠交叉验证找。 第三段多任务的岭系数比单任务大得多(3.0 对 1.0)。 这不奇怪:多任务里 lam 惩罚的是「私有部分的大小」,而这个数据里私有部分本来就小, 所以 lam 大一点正好把这个先验表达出来。 第四段那张表最值得反复看的是最后两列。 改善一路涨,岭系数也一路涨,两个趋势同步——说明任务越多,「大家共有」这个假设越可信, 也就越敢下重力去共享。 第五段第 8 个任务是 0.4791 降到 0.1980,降到了噪声下限 0.09 的两倍附近。 这说明它被修得相当好; 而第 5 个任务只从 0.7456 降到 0.5017,私有偏离可能恰好和公共项方向冲突。 多任务不是万灵药,它只保证平均更好。 代码里有一处值得注意:mt_fit 里那个 1e-6 * eye 是给 A 加的数值兜底。 因为 w0 的方程里 A 是各个任务 X 转置乘 X 之和,如果所有任务的样本都落在同一个低维子空间里, A 会奇异。 这个兜底不改变结果,只是防止解方程时崩掉。

30 秒实验

  1. 把 mt_data 的 own 从 0.15 改成 0.05,让任务之间更像,重跑。看多任务能好到什么程度。 任务越像,共享越值钱;own 改成 0.5 时多任务的收益会基本消失。
  2. 把 mt_data 的 n_per 从 12 改成 40,重跑。单任务会追上来,甚至反超——因为它自己就够用了。 多任务的价值只在「每个任务都不够用」时成立。
  3. 把 mt_fit 里的惩罚项改成对所有参数(w0 加 b_t)一起惩罚,重跑。 这时多任务会退化成各自加岭回归,两个数字会趋同。 共享的全部力量就来自「只惩罚私有部分」这一个设计。

常见误区

  1. 「任务越多越好,无脑加就行」。 多任务不是靠任务数量取胜,是靠「任务真的相似」取胜。 如果各任务毫无关系,共享的公共项就是噪声,会把每个任务都拉偏。 判断依据是:各任务单独估出来的参数,方向是不是大致一致。
  2. 「共享强度设大一点更稳」。 实测任务数只有 2 时,lam 从 0.01 加到 0.3,误差从 0.3634 恶化到 1.3929。 共享强度必须随任务数调:任务少只能弱共享,任务多才敢强共享。 这条规律在本节表里体现得很直接——选到的岭系数从 0.01 一路涨到 3。
  3. 「多任务一定比单任务好」。 本节 8 个任务确实全部变好,但那是造出来的数据,任务之间共享得很干净。 真实场景里只要有一两个任务和别的不像,它就会被公共项拖累。 稳妥的做法是允许某些任务「退出共享」——把它们的 lam 单独调小。
  4. 「公共项可以单独解释」。 w0 和 b_t 的划分不是唯一的:w0 加 c、b_t 减 c 给出完全相同的总参数。 是正则项把这个自由度定住了,所以 w0 的含义依赖 lam 的取值。 别把估出来的 w0 当成「任务的本质规律」去物理诠释。
自测三问(点开对答案) 1. 为什么惩罚项只加在任务的私有部分,不加在公共部分? —— 因为公共部分有所有任务的数据支撑,样本足够,不需要压; 而私有部分每个任务只有一点点样本,必须压住它才不至于过拟合。 如果连公共部分也惩罚,方法就退化成各任务独立加岭回归,共享失效; 如果两边都不惩罚,w0 和 b_t 可以互相抵消,解不唯一。 2. 任务数从 1 加到 12,改善从 1.00 倍涨到 2.07 倍,为什么? —— 因为公共项是拿所有任务的样本一起估的,任务越多,估得越准,各任务能借到的力就越大。 同时单任务的误差随任务数变差(样本随机性混进来),差距被进一步拉开。 注意多任务那一列的绝对值基本稳在 0.35 到 0.37,说明它的误差不随任务数恶化。 3. 为什么任务少的时候必须把岭系数调小? —— 因为共享的前提是「各任务真的相似」,而只有一两个任务时你无法判断这一点, 公共项只是一个没有根据的折中。强行让各任务靠近它,等于用一个偏的中心去拉偏。 实测任务数 2 时 lam 设 0.3,误差从 0.3634 恶化到 1.3929。

这一节的参数是实数,拟合用最小二乘,一切都还挺顺。 换成概率分类器就没这么舒服了:输出是概率,得保证非负、加起来等于一, 而最小二乘根本不管这些约束。 下一节把同一套共享结构搬过去,看看会多出哪些麻烦。

相关笔记