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 秒实验
- 把
mt_data的own从 0.15 改成 0.05,让任务之间更像,重跑。看多任务能好到什么程度。 任务越像,共享越值钱;own改成 0.5 时多任务的收益会基本消失。 - 把
mt_data的n_per从 12 改成 40,重跑。单任务会追上来,甚至反超——因为它自己就够用了。 多任务的价值只在「每个任务都不够用」时成立。 - 把
mt_fit里的惩罚项改成对所有参数(w0 加 b_t)一起惩罚,重跑。 这时多任务会退化成各自加岭回归,两个数字会趋同。 共享的全部力量就来自「只惩罚私有部分」这一个设计。
常见误区
- 「任务越多越好,无脑加就行」。 多任务不是靠任务数量取胜,是靠「任务真的相似」取胜。 如果各任务毫无关系,共享的公共项就是噪声,会把每个任务都拉偏。 判断依据是:各任务单独估出来的参数,方向是不是大致一致。
- 「共享强度设大一点更稳」。 实测任务数只有 2 时,lam 从 0.01 加到 0.3,误差从 0.3634 恶化到 1.3929。 共享强度必须随任务数调:任务少只能弱共享,任务多才敢强共享。 这条规律在本节表里体现得很直接——选到的岭系数从 0.01 一路涨到 3。
- 「多任务一定比单任务好」。 本节 8 个任务确实全部变好,但那是造出来的数据,任务之间共享得很干净。 真实场景里只要有一两个任务和别的不像,它就会被公共项拖累。 稳妥的做法是允许某些任务「退出共享」——把它们的 lam 单独调小。
- 「公共项可以单独解释」。 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。这一节的参数是实数,拟合用最小二乘,一切都还挺顺。 换成概率分类器就没这么舒服了:输出是概率,得保证非负、加起来等于一, 而最小二乘根本不管这些约束。 下一节把同一套共享结构搬过去,看看会多出哪些麻烦。
- ☐ (本节完,下一节 19.2 19.2)